mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 08:48:29 +00:00
Feat: Space (#93)
* Feat: change space assignment from statement to episode * feat: add default spaces and improve integration, space tools discovery in MCP * feat: change spaces to episode based * Feat: take multiple spaceIds while ingesting * Feat: modify mcp tool descriptions, add spaceId in mcp url * feat: add copy * bump: new version 0.1.24 --------- Co-authored-by: Manoj <saimanoj58@gmail.com>
This commit is contained in:
parent
27f8740691
commit
bcc0560cf0
@ -1,4 +1,4 @@
|
||||
VERSION=0.1.23
|
||||
VERSION=0.1.24
|
||||
|
||||
# Nest run in docker, change host to database container name
|
||||
DB_HOST=localhost
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const EnvironmentSchema = z.object({
|
||||
// Version
|
||||
VERSION: z.string().default("0.1.14"),
|
||||
VERSION: z.string().default("0.1.24"),
|
||||
|
||||
// Database
|
||||
DB_HOST: z.string().default("localhost"),
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
apps/webapp/app/components/command-bar/document-dialog.tsx
Normal file
27
apps/webapp/app/components/command-bar/document-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EllipsisVertical, Trash } from "lucide-react";
|
||||
import { EllipsisVertical, Trash, Copy } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
} from "../ui/alert-dialog";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { toast } from "~/hooks/use-toast";
|
||||
|
||||
interface LogOptionsProps {
|
||||
id: string;
|
||||
@ -40,8 +41,24 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast({
|
||||
title: "Copied",
|
||||
description: "Episode ID copied to clipboard",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy ID",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(deleteFetcher.state, deleteFetcher.data);
|
||||
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
|
||||
navigate(`/home/inbox`);
|
||||
}
|
||||
@ -49,16 +66,26 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-2 rounded"
|
||||
onClick={(e) => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash size={15} /> Delete
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-2 rounded"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy size={15} /> Copy ID
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-2 rounded"
|
||||
onClick={(e) => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash size={15} /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -22,5 +22,5 @@ export function getStatusValue(status: string) {
|
||||
return formatString("In Queue");
|
||||
}
|
||||
|
||||
return status;
|
||||
return formatString(status);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
167
apps/webapp/app/components/spaces/space-dropdown.tsx
Normal file
167
apps/webapp/app/components/spaces/space-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
@ -1,4 +1,4 @@
|
||||
import { EllipsisVertical, RefreshCcw, Trash, Edit } from "lucide-react";
|
||||
import { EllipsisVertical, RefreshCcw, Trash, Edit, Copy } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { EditSpaceDialog } from "./edit-space-dialog.client";
|
||||
import { toast } from "~/hooks/use-toast";
|
||||
|
||||
interface SpaceOptionsProps {
|
||||
id: string;
|
||||
@ -64,6 +65,23 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
|
||||
// revalidator.revalidate();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast({
|
||||
title: "Copied",
|
||||
description: "Space ID copied to clipboard",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to copy ID",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@ -79,6 +97,11 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<Button variant="link" size="sm" className="gap-2 rounded">
|
||||
<Copy size={15} /> Copy ID
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
|
||||
<Button variant="link" size="sm" className="gap-2 rounded">
|
||||
<Edit size={15} /> Edit
|
||||
|
||||
@ -9,8 +9,8 @@ interface SpacesGridProps {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
autoMode: boolean;
|
||||
statementCount: number | null;
|
||||
summary: string | null;
|
||||
contextCount?: number | null;
|
||||
themes?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -2,3 +2,5 @@ export * from "./button";
|
||||
export * from "./tabs";
|
||||
export * from "./input";
|
||||
export * from "./scrollarea";
|
||||
export * from "./toast";
|
||||
export * from "./toaster";
|
||||
|
||||
133
apps/webapp/app/components/ui/toast.tsx
Normal file
133
apps/webapp/app/components/ui/toast.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-3 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
warning: "warning group border-warning bg-warning text-foreground",
|
||||
success: "success group border-success bg-success text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toastVariants({ variant }),
|
||||
className,
|
||||
"shadow-1 rounded-md border-0 bg-gray-100 font-sans backdrop-blur-md",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("font-medium [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
33
apps/webapp/app/components/ui/toaster.tsx
Normal file
33
apps/webapp/app/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "~/components/ui/toast";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,8 @@ export interface LogItem {
|
||||
activityId?: string;
|
||||
episodeUUID?: string;
|
||||
data?: any;
|
||||
spaceIds?: string[];
|
||||
episodeDetails?: any;
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
|
||||
186
apps/webapp/app/hooks/use-toast.ts
Normal file
186
apps/webapp/app/hooks/use-toast.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,6 @@ export const addToQueue = async (
|
||||
|
||||
const queuePersist = await prisma.ingestionQueue.create({
|
||||
data: {
|
||||
spaceId: body.spaceId ? body.spaceId : null,
|
||||
data: body,
|
||||
type: body.type,
|
||||
status: IngestionStatus.PENDING,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -14,14 +14,26 @@ interface CreateWorkspaceDto {
|
||||
const spaceService = new SpaceService();
|
||||
|
||||
const profileRule = `
|
||||
Store the user’s stable, non-sensitive identity and preference facts that improve personalization across assistants. Facts must be long-lived (expected validity ≥ 3 months) and broadly useful across contexts (not app-specific).
|
||||
Purpose: Store my identity and preferences to improve personalization across assistants. It should be broadly useful across contexts (not app-specific).
|
||||
Include (examples):
|
||||
• Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain
|
||||
• Timezone, locale, working hours, meeting preferences (async/sync bias, default duration)
|
||||
• Role, team, company, office location (city-level only), seniority
|
||||
• Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS
|
||||
• Communication preferences (tone, brevity vs. detail, summary-first)
|
||||
Exclude: secrets/credentials; one-off or short-term states; health/financial/political/religious/sexual data; precise home address; raw event logs; app-specific analytics; anything the user did not explicitly consent to share.`;
|
||||
Exclude:
|
||||
• Sensitive: secrets, health/financial/political/religious/sexual data, precise address
|
||||
• Temporary: one-off states, troubleshooting sessions, query results
|
||||
• Context-specific: app behaviors, work conversations, project-specific preferences
|
||||
• Meta: discussions about this memory system, AI architecture, system design
|
||||
• Anything not explicitly consented to share
|
||||
don't store anything the user did not explicitly consent to share.`;
|
||||
|
||||
const githubDescription = `Everything related to my GitHub work - repos I'm working on, projects I contribute to, code I'm writing, PRs I'm reviewing. Basically my coding life on GitHub.`;
|
||||
|
||||
const healthDescription = `My health and wellness stuff - how I'm feeling, what I'm learning about my body, experiments I'm trying, patterns I notice. Whatever matters to me about staying healthy.`;
|
||||
|
||||
const fitnessDescription = `My workouts and training - what I'm doing at the gym, runs I'm going on, progress I'm making, goals I'm chasing. Anything related to physical exercise and getting stronger.`;
|
||||
|
||||
export async function createWorkspace(
|
||||
input: CreateWorkspaceDto,
|
||||
@ -43,12 +55,33 @@ export async function createWorkspace(
|
||||
|
||||
await ensureBillingInitialized(workspace.id);
|
||||
|
||||
await spaceService.createSpace({
|
||||
name: "Profile",
|
||||
description: profileRule,
|
||||
userId: input.userId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
// Create default spaces
|
||||
await Promise.all([
|
||||
spaceService.createSpace({
|
||||
name: "Profile",
|
||||
description: profileRule,
|
||||
userId: input.userId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
spaceService.createSpace({
|
||||
name: "GitHub",
|
||||
description: githubDescription,
|
||||
userId: input.userId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
spaceService.createSpace({
|
||||
name: "Health",
|
||||
description: healthDescription,
|
||||
userId: input.userId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
spaceService.createSpace({
|
||||
name: "Fitness",
|
||||
description: fitnessDescription,
|
||||
userId: input.userId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await sendEmail({ email: "welcome", to: user.email });
|
||||
|
||||
@ -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,8 @@ import {
|
||||
useTheme,
|
||||
} from "remix-themes";
|
||||
import clsx from "clsx";
|
||||
import { getUsageSummary } from "./services/billing.server";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||
|
||||
@ -50,12 +52,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,
|
||||
@ -124,6 +127,7 @@ function App() {
|
||||
</head>
|
||||
<body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans">
|
||||
<Outlet />
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
|
||||
<Scripts />
|
||||
|
||||
66
apps/webapp/app/routes/api.v1.episodes.assign-space.ts
Normal file
66
apps/webapp/app/routes/api.v1.episodes.assign-space.ts
Normal 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 };
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { SpaceService } from "~/services/space.server";
|
||||
import { json } from "@remix-run/node";
|
||||
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||
|
||||
const spaceService = new SpaceService();
|
||||
|
||||
@ -29,18 +30,20 @@ const { loader } = createActionApiRoute(
|
||||
return json({ error: "Space not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get statements in the space
|
||||
const statements = await spaceService.getSpaceStatements(spaceId, userId);
|
||||
// Get episodes in the space
|
||||
const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
|
||||
const episodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
|
||||
return json({
|
||||
statements,
|
||||
return json({
|
||||
episodes,
|
||||
space: {
|
||||
uuid: space.uuid,
|
||||
name: space.name,
|
||||
statementCount: statements.length
|
||||
description: space.description,
|
||||
episodeCount,
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export { loader };
|
||||
export { loader };
|
||||
@ -1,16 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createHybridActionApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { SpaceService } from "~/services/space.server";
|
||||
import { json } from "@remix-run/node";
|
||||
import {
|
||||
createSpace,
|
||||
deleteSpace,
|
||||
updateSpace,
|
||||
} from "~/services/graphModels/space";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
||||
|
||||
@ -33,45 +24,26 @@ const { loader, action } = createHybridActionApiRoute(
|
||||
const { spaceId } = params;
|
||||
const spaceService = new SpaceService();
|
||||
|
||||
// Verify space exists and belongs to user
|
||||
const space = await prisma.space.findUnique({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
});
|
||||
if (!space) {
|
||||
return json({ error: "Space not found" }, { status: 404 });
|
||||
}
|
||||
// Reset the space (clears all assignments, summary, and metadata)
|
||||
const space = await spaceService.resetSpace(spaceId, userId);
|
||||
|
||||
// Get statements in the space
|
||||
await deleteSpace(spaceId, userId);
|
||||
logger.info(`Reset space ${space.id} successfully`);
|
||||
|
||||
await createSpace(
|
||||
space.id,
|
||||
space.name.trim(),
|
||||
space.description?.trim(),
|
||||
userId,
|
||||
);
|
||||
|
||||
await spaceService.updateSpace(space.id, { status: "pending" }, userId);
|
||||
|
||||
logger.info(`Created space ${space.id} successfully`);
|
||||
|
||||
// Trigger automatic LLM assignment for the new space
|
||||
// Trigger automatic episode assignment for the reset space
|
||||
try {
|
||||
await triggerSpaceAssignment({
|
||||
userId: userId,
|
||||
workspaceId: space.workspaceId,
|
||||
mode: "new_space",
|
||||
newSpaceId: space.id,
|
||||
batchSize: 25, // Analyze recent statements for the new space
|
||||
batchSize: 20, // Analyze recent episodes for reassignment
|
||||
});
|
||||
|
||||
logger.info(`Triggered LLM space assignment for new space ${space.id}`);
|
||||
logger.info(`Triggered space assignment for reset space ${space.id}`);
|
||||
} catch (error) {
|
||||
// Don't fail space creation if LLM assignment fails
|
||||
// Don't fail space reset if assignment fails
|
||||
logger.warn(
|
||||
`Failed to trigger LLM assignment for space ${space.id}:`,
|
||||
`Failed to trigger assignment for space ${space.id}:`,
|
||||
error as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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}
|
||||
@ -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 ${
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -153,6 +153,7 @@ export default function Onboarding() {
|
||||
setCurrentQuestion(currentQuestion + 1);
|
||||
} else {
|
||||
setLoading(true);
|
||||
|
||||
// Submit all answers
|
||||
submitAnswers();
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -56,13 +56,12 @@ export async function getSpace(
|
||||
const query = `
|
||||
MATCH (s:Space {uuid: $spaceId, userId: $userId})
|
||||
WHERE s.isActive = true
|
||||
|
||||
// Count statements in this space using optimized approach
|
||||
OPTIONAL MATCH (stmt:Statement {userId: $userId})
|
||||
WHERE stmt.spaceIds IS NOT NULL AND $spaceId IN stmt.spaceIds AND stmt.invalidAt IS NULL
|
||||
|
||||
WITH s, count(stmt) as statementCount
|
||||
RETURN s, statementCount
|
||||
|
||||
// Count episodes assigned to this space using direct relationship
|
||||
OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
|
||||
WITH s, count(e) as episodeCount
|
||||
RETURN s, episodeCount
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
@ -71,7 +70,7 @@ export async function getSpace(
|
||||
}
|
||||
|
||||
const spaceData = result[0].get("s").properties;
|
||||
const statementCount = result[0].get("statementCount") || 0;
|
||||
const episodeCount = result[0].get("episodeCount") || 0;
|
||||
|
||||
return {
|
||||
uuid: spaceData.uuid,
|
||||
@ -81,7 +80,7 @@ export async function getSpace(
|
||||
createdAt: new Date(spaceData.createdAt),
|
||||
updatedAt: new Date(spaceData.updatedAt),
|
||||
isActive: spaceData.isActive,
|
||||
statementCount: Number(statementCount),
|
||||
contextCount: Number(episodeCount), // Episode count = context count
|
||||
};
|
||||
}
|
||||
|
||||
@ -151,28 +150,53 @@ export async function deleteSpace(
|
||||
}
|
||||
|
||||
// 2. Clean up statement references (remove spaceId from spaceIds arrays)
|
||||
const cleanupQuery = `
|
||||
const cleanupStatementsQuery = `
|
||||
MATCH (s:Statement {userId: $userId})
|
||||
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
|
||||
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
|
||||
RETURN count(s) as updatedStatements
|
||||
`;
|
||||
|
||||
const cleanupResult = await runQuery(cleanupQuery, { userId, spaceId });
|
||||
const updatedStatements = cleanupResult[0]?.get("updatedStatements") || 0;
|
||||
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
|
||||
userId,
|
||||
spaceId,
|
||||
});
|
||||
const updatedStatements =
|
||||
cleanupStatementsResult[0]?.get("updatedStatements") || 0;
|
||||
|
||||
// 3. Delete the space node
|
||||
// 3. Clean up episode references (remove spaceId from spaceIds arrays)
|
||||
const cleanupEpisodesQuery = `
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
|
||||
RETURN count(e) as updatedEpisodes
|
||||
`;
|
||||
|
||||
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 = `
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||
DELETE space
|
||||
DETACH DELETE space
|
||||
RETURN count(space) as deletedSpaces
|
||||
`;
|
||||
|
||||
await runQuery(deleteQuery, { userId, spaceId });
|
||||
|
||||
logger.info(`Deleted space ${spaceId}`, {
|
||||
userId,
|
||||
statementsUpdated: updatedStatements,
|
||||
episodesUpdated: updatedEpisodes,
|
||||
});
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
statementsUpdated: Number(updatedStatements),
|
||||
statementsUpdated: Number(updatedStatements) + Number(updatedEpisodes),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -184,10 +208,10 @@ export async function deleteSpace(
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign statements to a space
|
||||
* Assign episodes to a space using intent-based matching
|
||||
*/
|
||||
export async function assignStatementsToSpace(
|
||||
statementIds: string[],
|
||||
export async function assignEpisodesToSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<SpaceAssignmentResult> {
|
||||
@ -202,30 +226,48 @@ export async function assignStatementsToSpace(
|
||||
};
|
||||
}
|
||||
|
||||
// Update episodes with spaceIds array AND create HAS_EPISODE relationships
|
||||
// This hybrid approach enables both fast array lookups and graph traversal
|
||||
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]
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.uuid IN $episodeIds
|
||||
SET e.spaceIds = CASE
|
||||
WHEN e.spaceIds IS NULL THEN [$spaceId]
|
||||
WHEN $spaceId IN e.spaceIds THEN e.spaceIds
|
||||
ELSE e.spaceIds + [$spaceId]
|
||||
END,
|
||||
s.lastSpaceAssignment = datetime(),
|
||||
s.spaceAssignmentMethod = CASE
|
||||
WHEN s.spaceAssignmentMethod IS NULL THEN 'manual'
|
||||
ELSE s.spaceAssignmentMethod
|
||||
e.lastSpaceAssignment = datetime(),
|
||||
e.spaceAssignmentMethod = CASE
|
||||
WHEN e.spaceAssignmentMethod IS NULL THEN 'intent_based'
|
||||
ELSE e.spaceAssignmentMethod
|
||||
END
|
||||
RETURN count(s) as updated
|
||||
WITH e, space
|
||||
MERGE (space)-[r:HAS_EPISODE]->(e)
|
||||
ON CREATE SET
|
||||
r.assignedAt = datetime(),
|
||||
r.assignmentMethod = 'intent_based'
|
||||
RETURN count(e) as updated
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { statementIds, spaceId, userId });
|
||||
const result = await runQuery(query, { episodeIds, spaceId, userId });
|
||||
const updatedCount = result[0]?.get("updated") || 0;
|
||||
|
||||
logger.info(`Assigned ${updatedCount} episodes to space ${spaceId}`, {
|
||||
episodeIds: episodeIds.length,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
statementsUpdated: Number(updatedCount),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error assigning episodes to space:`, {
|
||||
error,
|
||||
spaceId,
|
||||
episodeIds: episodeIds.length,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
statementsUpdated: 0,
|
||||
@ -235,22 +277,26 @@ export async function assignStatementsToSpace(
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove statements from a space
|
||||
* Remove episodes from a space
|
||||
*/
|
||||
export async function removeStatementsFromSpace(
|
||||
statementIds: string[],
|
||||
export async function removeEpisodesFromSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<SpaceAssignmentResult> {
|
||||
try {
|
||||
// Remove from both spaceIds array and HAS_EPISODE relationship
|
||||
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
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.uuid IN $episodeIds AND e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
|
||||
WITH e
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[r:HAS_EPISODE]->(e)
|
||||
DELETE r
|
||||
RETURN count(e) as updated
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { statementIds, spaceId, userId });
|
||||
const result = await runQuery(query, { episodeIds, spaceId, userId });
|
||||
const updatedCount = result[0]?.get("updated") || 0;
|
||||
|
||||
return {
|
||||
@ -267,199 +313,79 @@ export async function removeStatementsFromSpace(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all statements in a space
|
||||
* Get all episodes in a space
|
||||
*/
|
||||
export async function getSpaceStatements(spaceId: string, userId: string) {
|
||||
export async function getSpaceEpisodes(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
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
RETURN e
|
||||
ORDER BY e.createdAt DESC
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
|
||||
return result.map((record) => {
|
||||
const statement = record.get("s").properties;
|
||||
const episode = record.get("e").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,
|
||||
uuid: episode.uuid,
|
||||
content: episode.content,
|
||||
originalContent: episode.originalContent,
|
||||
source: episode.source,
|
||||
createdAt: new Date(episode.createdAt),
|
||||
validAt: new Date(episode.validAt),
|
||||
metadata: JSON.parse(episode.metadata || "{}"),
|
||||
sessionId: episode.sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time statement count for a space from Neo4j
|
||||
* Get episode count for a space
|
||||
*/
|
||||
export async function getSpaceStatementCount(
|
||||
export async function getSpaceEpisodeCount(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
// Use spaceIds array for faster lookup instead of relationship traversal
|
||||
const query = `
|
||||
MATCH (s:Statement {userId: $userId})
|
||||
WHERE s.spaceIds IS NOT NULL
|
||||
AND $spaceId IN s.spaceIds
|
||||
RETURN count(s) as statementCount
|
||||
MATCH (e:Episode {userId: $userId})
|
||||
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||
RETURN count(e) as episodeCount
|
||||
`;
|
||||
|
||||
const result = await runQuery(query, { spaceId, userId });
|
||||
return Number(result[0]?.get("statementCount") || 0);
|
||||
return Number(result[0]?.get("episodeCount") || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a space should trigger pattern analysis based on growth thresholds
|
||||
* Get spaces for specific episodes
|
||||
*/
|
||||
export async function shouldTriggerSpacePattern(
|
||||
spaceId: string,
|
||||
export async function getSpacesForEpisodes(
|
||||
episodeIds: string[],
|
||||
userId: string,
|
||||
): Promise<{
|
||||
shouldTrigger: boolean;
|
||||
isNewSpace: boolean;
|
||||
currentCount: number;
|
||||
}> {
|
||||
try {
|
||||
// Get current statement count from Neo4j
|
||||
const currentCount = await getSpaceStatementCount(spaceId, userId);
|
||||
): 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
|
||||
`;
|
||||
|
||||
// Get space data from PostgreSQL
|
||||
const space = await prisma.space.findUnique({
|
||||
where: { id: spaceId },
|
||||
select: {
|
||||
lastPatternTrigger: true,
|
||||
statementCountAtLastTrigger: true,
|
||||
},
|
||||
});
|
||||
const result = await runQuery(query, { episodeIds, userId });
|
||||
|
||||
if (!space) {
|
||||
logger.warn(`Space ${spaceId} not found when checking pattern trigger`);
|
||||
return { shouldTrigger: false, isNewSpace: false, currentCount };
|
||||
}
|
||||
const spacesMap: Record<string, string[]> = {};
|
||||
|
||||
const isNewSpace = !space.lastPatternTrigger;
|
||||
const previousCount = space.statementCountAtLastTrigger || 0;
|
||||
const growth = currentCount - previousCount;
|
||||
// Initialize all episodes with empty arrays
|
||||
episodeIds.forEach((id) => {
|
||||
spacesMap[id] = [];
|
||||
});
|
||||
|
||||
// Trigger if: new space OR growth >= 100 statements
|
||||
const shouldTrigger = isNewSpace || growth >= 100;
|
||||
// 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 || [];
|
||||
});
|
||||
|
||||
logger.info(`Space pattern trigger check`, {
|
||||
spaceId,
|
||||
currentCount,
|
||||
previousCount,
|
||||
growth,
|
||||
isNewSpace,
|
||||
shouldTrigger,
|
||||
});
|
||||
|
||||
return { shouldTrigger, isNewSpace, currentCount };
|
||||
} catch (error) {
|
||||
logger.error(`Error checking space pattern trigger:`, {
|
||||
error,
|
||||
spaceId,
|
||||
userId,
|
||||
});
|
||||
return { shouldTrigger: false, isNewSpace: false, currentCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update pattern trigger timestamp and statement count to prevent race conditions
|
||||
*/
|
||||
export async function atomicUpdatePatternTrigger(
|
||||
spaceId: string,
|
||||
currentCount: number,
|
||||
): Promise<{ updated: boolean; isNewSpace: boolean } | null> {
|
||||
try {
|
||||
// Use a transaction to atomically check and update
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Get current state
|
||||
const space = await tx.space.findUnique({
|
||||
where: { id: spaceId },
|
||||
select: {
|
||||
lastPatternTrigger: true,
|
||||
statementCountAtLastTrigger: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!space) {
|
||||
throw new Error(`Space ${spaceId} not found`);
|
||||
}
|
||||
|
||||
const isNewSpace = !space.lastPatternTrigger;
|
||||
const previousCount = space.statementCountAtLastTrigger || 0;
|
||||
const growth = currentCount - previousCount;
|
||||
|
||||
// Double-check if we still need to trigger (race condition protection)
|
||||
const shouldTrigger = isNewSpace || growth >= 100;
|
||||
|
||||
if (!shouldTrigger) {
|
||||
return { updated: false, isNewSpace: false };
|
||||
}
|
||||
|
||||
// Update the trigger timestamp and count atomically
|
||||
await tx.space.update({
|
||||
where: { id: spaceId },
|
||||
data: {
|
||||
lastPatternTrigger: new Date(),
|
||||
statementCountAtLastTrigger: currentCount,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Atomically updated pattern trigger for space`, {
|
||||
spaceId,
|
||||
previousCount,
|
||||
currentCount,
|
||||
growth,
|
||||
isNewSpace,
|
||||
});
|
||||
|
||||
return { updated: true, isNewSpace };
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error in atomic pattern trigger update:`, {
|
||||
error,
|
||||
spaceId,
|
||||
currentCount,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
return spacesMap;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -19,7 +19,8 @@ import { ensureBillingInitialized } from "./billing.server";
|
||||
const QueryParams = z.object({
|
||||
source: z.string().optional(),
|
||||
integrations: z.string().optional(), // comma-separated slugs
|
||||
no_integrations: z.boolean().optional(), // comma-separated slugs
|
||||
no_integrations: z.boolean().optional(),
|
||||
spaceId: z.string().optional(), // space UUID to associate memories with
|
||||
});
|
||||
|
||||
// Create MCP server with memory tools + dynamic integration tools
|
||||
@ -27,6 +28,7 @@ async function createMcpServer(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
source: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const server = new Server(
|
||||
{
|
||||
@ -40,19 +42,12 @@ async function createMcpServer(
|
||||
},
|
||||
);
|
||||
|
||||
// Dynamic tool listing that includes integration tools
|
||||
// Dynamic tool listing - only expose memory tools and meta-tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
// Get integration tools
|
||||
let integrationTools: any[] = [];
|
||||
try {
|
||||
integrationTools =
|
||||
await IntegrationLoader.getAllIntegrationTools(sessionId);
|
||||
} catch (error) {
|
||||
logger.error(`Error loading integration tools: ${error}`);
|
||||
}
|
||||
|
||||
// Only return memory tools (which now includes integration meta-tools)
|
||||
// Integration-specific tools are discovered via get_integration_actions
|
||||
return {
|
||||
tools: [...memoryTools, ...integrationTools],
|
||||
tools: memoryTools,
|
||||
};
|
||||
});
|
||||
|
||||
@ -60,30 +55,21 @@ async function createMcpServer(
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
// Handle memory tools
|
||||
if (name.startsWith("memory_")) {
|
||||
return await callMemoryTool(name, args, userId, source);
|
||||
}
|
||||
|
||||
// Handle integration tools (prefixed with integration slug)
|
||||
if (name.includes("_") && !name.startsWith("memory_")) {
|
||||
try {
|
||||
return await IntegrationLoader.callIntegrationTool(
|
||||
sessionId,
|
||||
name,
|
||||
args,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error calling integration tool: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// Handle memory tools and integration meta-tools
|
||||
if (
|
||||
name.startsWith("memory_") ||
|
||||
name === "get_integrations" ||
|
||||
name === "get_integration_actions" ||
|
||||
name === "execute_integration_action"
|
||||
) {
|
||||
// Get workspace for integration tools
|
||||
const workspace = await getWorkspaceByUser(userId);
|
||||
return await callMemoryTool(
|
||||
name,
|
||||
{ ...args, sessionId, workspaceId: workspace?.id, spaceId },
|
||||
userId,
|
||||
source,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
@ -100,6 +86,7 @@ async function createTransport(
|
||||
noIntegrations: boolean,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
spaceId?: string,
|
||||
): Promise<StreamableHTTPServerTransport> {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId,
|
||||
@ -171,7 +158,7 @@ async function createTransport(
|
||||
}
|
||||
|
||||
// Create and connect MCP server
|
||||
const server = await createMcpServer(userId, sessionId, source);
|
||||
const server = await createMcpServer(userId, sessionId, source, spaceId);
|
||||
await server.connect(transport);
|
||||
|
||||
return transport;
|
||||
@ -191,6 +178,7 @@ export const handleMCPRequest = async (
|
||||
: [];
|
||||
|
||||
const noIntegrations = queryParams.no_integrations ?? false;
|
||||
const spaceId = queryParams.spaceId; // Extract spaceId from query params
|
||||
|
||||
const userId = authentication.userId;
|
||||
const workspace = await getWorkspaceByUser(userId);
|
||||
@ -220,6 +208,7 @@ export const handleMCPRequest = async (
|
||||
noIntegrations,
|
||||
userId,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Session not found in database");
|
||||
@ -237,6 +226,7 @@ export const handleMCPRequest = async (
|
||||
noIntegrations,
|
||||
userId,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
);
|
||||
} else {
|
||||
// Invalid request
|
||||
|
||||
@ -33,7 +33,7 @@ export class SearchService {
|
||||
options: SearchOptions = {},
|
||||
source?: string,
|
||||
): Promise<{
|
||||
episodes: string[];
|
||||
episodes: {content: string; createdAt: Date; spaceIds: string[]}[];
|
||||
facts: {
|
||||
fact: string;
|
||||
validAt: Date;
|
||||
@ -108,7 +108,11 @@ export class SearchService {
|
||||
);
|
||||
|
||||
return {
|
||||
episodes: episodes.map((episode) => episode.originalContent),
|
||||
episodes: episodes.map((episode) => ({
|
||||
content: episode.originalContent,
|
||||
createdAt: episode.createdAt,
|
||||
spaceIds: episode.spaceIds || [],
|
||||
})),
|
||||
facts: filteredResults.map((statement) => ({
|
||||
fact: statement.statement.fact,
|
||||
validAt: statement.statement.validAt,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -3,19 +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 {
|
||||
assignStatementsToSpace,
|
||||
assignEpisodesToSpace,
|
||||
createSpace,
|
||||
deleteSpace,
|
||||
getSpace,
|
||||
getSpaceStatements,
|
||||
initializeStatementSpaceIds,
|
||||
removeStatementsFromSpace,
|
||||
getSpaceEpisodeCount,
|
||||
getSpaceEpisodes,
|
||||
removeEpisodesFromSpace,
|
||||
updateSpace,
|
||||
} from "./graphModels/space";
|
||||
import { prisma } from "~/trigger/utils/prisma";
|
||||
@ -182,13 +181,6 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
updates.description !== undefined &&
|
||||
updates.description.length > 1000
|
||||
) {
|
||||
throw new Error("Space description too long (max 1000 characters)");
|
||||
}
|
||||
|
||||
const space = await prisma.space.update({
|
||||
where: {
|
||||
id: spaceId,
|
||||
@ -233,87 +225,101 @@ export class SpaceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign statements to a space
|
||||
* Reset a space by clearing all episode assignments, summary, and metadata
|
||||
*/
|
||||
async assignStatementsToSpace(
|
||||
statementIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): Promise<SpaceAssignmentResult> {
|
||||
logger.info(
|
||||
`Assigning ${statementIds.length} statements to space ${spaceId} for user ${userId}`,
|
||||
);
|
||||
async resetSpace(spaceId: string, userId: string): Promise<Space> {
|
||||
logger.info(`Resetting space ${spaceId} for user ${userId}`);
|
||||
|
||||
// Validate input
|
||||
if (statementIds.length === 0) {
|
||||
throw new Error("No statement IDs provided");
|
||||
// Get the space first to verify it exists and get its details
|
||||
const space = await prisma.space.findUnique({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!space) {
|
||||
throw new Error("Space not found");
|
||||
}
|
||||
|
||||
if (statementIds.length > 1000) {
|
||||
throw new Error("Too many statements (max 1000 per operation)");
|
||||
if (space.name === "Profile") {
|
||||
throw new Error("Cannot reset Profile space");
|
||||
}
|
||||
|
||||
const result = await assignStatementsToSpace(statementIds, spaceId, userId);
|
||||
// Delete all relationships in Neo4j (episodes, statements, etc.)
|
||||
await deleteSpace(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,
|
||||
// Recreate the space in Neo4j (clean slate)
|
||||
await createSpace(
|
||||
space.id,
|
||||
space.name.trim(),
|
||||
space.description?.trim(),
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
// Reset all summary and metadata fields in PostgreSQL
|
||||
const resetSpace = await prisma.space.update({
|
||||
where: {
|
||||
id: spaceId,
|
||||
},
|
||||
data: {
|
||||
summary: null,
|
||||
themes: [],
|
||||
contextCount: null,
|
||||
status: "pending",
|
||||
summaryGeneratedAt: null,
|
||||
lastPatternTrigger: null,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
logger.info(`Reset space ${spaceId} successfully`);
|
||||
|
||||
return resetSpace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all statements in a space
|
||||
* Get all episodes in a space
|
||||
*/
|
||||
async getSpaceStatements(spaceId: string, userId: string) {
|
||||
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`);
|
||||
return await getSpaceStatements(spaceId, userId);
|
||||
async getSpaceEpisodes(spaceId: string, userId: string) {
|
||||
logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
|
||||
return await getSpaceEpisodes(spaceId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign episodes to a space
|
||||
*/
|
||||
async assignEpisodesToSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
logger.info(
|
||||
`Assigning ${episodeIds.length} episodes to space ${spaceId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await assignEpisodesToSpace(episodeIds, spaceId, userId);
|
||||
|
||||
logger.info(
|
||||
`Successfully assigned ${episodeIds.length} episodes to space ${spaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove episodes from a space
|
||||
*/
|
||||
async removeEpisodesFromSpace(
|
||||
episodeIds: string[],
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
logger.info(
|
||||
`Removing ${episodeIds.length} episodes from space ${spaceId} for user ${userId}`,
|
||||
);
|
||||
|
||||
await removeEpisodesFromSpace(episodeIds, spaceId, userId);
|
||||
|
||||
logger.info(
|
||||
`Successfully removed ${episodeIds.length} episodes from space ${spaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -338,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
|
||||
*/
|
||||
@ -388,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +181,7 @@ export const ingestDocumentTask = task({
|
||||
documentUuid: document.uuid,
|
||||
},
|
||||
source: documentBody.source,
|
||||
spaceId: documentBody.spaceId,
|
||||
spaceIds: documentBody.spaceIds,
|
||||
sessionId: documentBody.sessionId,
|
||||
type: EpisodeTypeEnum.DOCUMENT,
|
||||
};
|
||||
|
||||
@ -9,13 +9,14 @@ import { triggerSpaceAssignment } from "../spaces/space-assignment";
|
||||
import { prisma } from "../utils/prisma";
|
||||
import { EpisodeType } from "@core/types";
|
||||
import { deductCredits, hasCredits } from "../utils/utils";
|
||||
import { assignEpisodesToSpace } from "~/services/graphModels/space";
|
||||
|
||||
export const IngestBodyRequest = z.object({
|
||||
episodeBody: z.string(),
|
||||
referenceTime: z.string(),
|
||||
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||
source: z.string(),
|
||||
spaceId: z.string().optional(),
|
||||
spaceIds: z.array(z.string()).optional(),
|
||||
sessionId: z.string().optional(),
|
||||
type: z
|
||||
.enum([EpisodeType.CONVERSATION, EpisodeType.DOCUMENT])
|
||||
@ -148,23 +149,49 @@ export const ingestTask = task({
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger space assignment after successful ingestion
|
||||
// Handle space assignment after successful ingestion
|
||||
try {
|
||||
logger.info(`Triggering space assignment after successful ingestion`, {
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
episodeId: episodeDetails?.episodeUuid,
|
||||
});
|
||||
if (
|
||||
episodeDetails.episodeUuid &&
|
||||
currentStatus === IngestionStatus.COMPLETED
|
||||
) {
|
||||
await triggerSpaceAssignment({
|
||||
// If spaceIds were explicitly provided, immediately assign the episode to those spaces
|
||||
if (episodeBody.spaceIds && episodeBody.spaceIds.length > 0 && episodeDetails.episodeUuid) {
|
||||
logger.info(`Assigning episode to explicitly provided spaces`, {
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
mode: "episode",
|
||||
episodeIds: episodeUuids,
|
||||
episodeId: episodeDetails.episodeUuid,
|
||||
spaceIds: episodeBody.spaceIds,
|
||||
});
|
||||
|
||||
// Assign episode to each space
|
||||
for (const spaceId of episodeBody.spaceIds) {
|
||||
await assignEpisodesToSpace(
|
||||
[episodeDetails.episodeUuid],
|
||||
spaceId,
|
||||
payload.userId,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Skipping LLM space assignment - episode explicitly assigned to ${episodeBody.spaceIds.length} space(s)`,
|
||||
);
|
||||
} else {
|
||||
// Only trigger automatic LLM space assignment if no explicit spaceIds were provided
|
||||
logger.info(
|
||||
`Triggering LLM space assignment after successful ingestion`,
|
||||
{
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
episodeId: episodeDetails?.episodeUuid,
|
||||
},
|
||||
);
|
||||
if (
|
||||
episodeDetails.episodeUuid &&
|
||||
currentStatus === IngestionStatus.COMPLETED
|
||||
) {
|
||||
await triggerSpaceAssignment({
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
mode: "episode",
|
||||
episodeIds: episodeUuids,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (assignmentError) {
|
||||
// Don't fail the ingestion if assignment fails
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -10,7 +10,7 @@ import { triggerSpacePattern } from "./space-pattern";
|
||||
import { getSpace, updateSpace } from "../utils/space-utils";
|
||||
|
||||
import { EpisodeType } from "@core/types";
|
||||
import { getSpaceStatementCount } from "~/services/graphModels/space";
|
||||
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||
import { addToQueue } from "../utils/queue";
|
||||
|
||||
interface SpaceSummaryPayload {
|
||||
@ -35,7 +35,7 @@ interface SpaceSummaryData {
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
spaceDescription?: string;
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
summary: string;
|
||||
keyEntities: string[];
|
||||
themes: string[];
|
||||
@ -55,7 +55,7 @@ const SummaryResultSchema = z.object({
|
||||
const CONFIG = {
|
||||
maxEpisodesForSummary: 20, // Limit episodes for performance
|
||||
minEpisodesForSummary: 1, // Minimum episodes to generate summary
|
||||
summaryPromptTokenLimit: 4000, // Approximate token limit for prompt
|
||||
summaryEpisodeThreshold: 10, // Minimum new episodes required to trigger summary (configurable)
|
||||
};
|
||||
|
||||
export const spaceSummaryQueue = queue({
|
||||
@ -85,7 +85,7 @@ export const spaceSummaryTask = task({
|
||||
});
|
||||
|
||||
// Generate summary for the single space
|
||||
const summaryResult = await generateSpaceSummary(spaceId, userId);
|
||||
const summaryResult = await generateSpaceSummary(spaceId, userId, triggerSource);
|
||||
|
||||
if (summaryResult) {
|
||||
// Store the summary
|
||||
@ -98,36 +98,24 @@ export const spaceSummaryTask = task({
|
||||
metadata: {
|
||||
triggerSource,
|
||||
phase: "completed_summary",
|
||||
statementCount: summaryResult.statementCount,
|
||||
contextCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Generated summary for space ${spaceId}`, {
|
||||
statementCount: summaryResult.statementCount,
|
||||
statementCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
themes: summaryResult.themes.length,
|
||||
triggerSource,
|
||||
});
|
||||
|
||||
// Ingest summary as document if it exists and continue with patterns
|
||||
if (!summaryResult.isIncremental && summaryResult.statementCount > 0) {
|
||||
await processSpaceSummarySequentially({
|
||||
userId,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
spaceName: summaryResult.spaceName,
|
||||
summaryContent: summaryResult.summary,
|
||||
triggerSource: "summary_complete",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
spaceId,
|
||||
triggerSource,
|
||||
summary: {
|
||||
statementCount: summaryResult.statementCount,
|
||||
statementCount: summaryResult.contextCount,
|
||||
confidence: summaryResult.confidence,
|
||||
themesCount: summaryResult.themes.length,
|
||||
},
|
||||
@ -186,6 +174,7 @@ export const spaceSummaryTask = task({
|
||||
async function generateSpaceSummary(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
triggerSource?: "assignment" | "manual" | "scheduled",
|
||||
): Promise<SpaceSummaryData | null> {
|
||||
try {
|
||||
// 1. Get space details
|
||||
@ -197,6 +186,35 @@ async function generateSpaceSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Check episode count threshold (skip for manual triggers)
|
||||
if (triggerSource !== "manual") {
|
||||
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
const lastSummaryEpisodeCount = space.contextCount || 0;
|
||||
const episodeDifference = currentEpisodeCount - lastSummaryEpisodeCount;
|
||||
|
||||
if (episodeDifference < CONFIG.summaryEpisodeThreshold) {
|
||||
logger.info(
|
||||
`Skipping summary generation for space ${spaceId}: only ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
|
||||
{
|
||||
currentEpisodeCount,
|
||||
lastSummaryEpisodeCount,
|
||||
episodeDifference,
|
||||
threshold: CONFIG.summaryEpisodeThreshold,
|
||||
}
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Proceeding with summary generation for space ${spaceId}: ${episodeDifference} new episodes (threshold: ${CONFIG.summaryEpisodeThreshold})`,
|
||||
{
|
||||
currentEpisodeCount,
|
||||
lastSummaryEpisodeCount,
|
||||
episodeDifference,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check for existing summary
|
||||
const existingSummary = await getExistingSummary(spaceId);
|
||||
const isIncremental = existingSummary !== null;
|
||||
@ -296,14 +314,14 @@ async function generateSpaceSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the actual current statement count from Neo4j
|
||||
const currentStatementCount = await getSpaceStatementCount(spaceId, userId);
|
||||
// Get the actual current counts from Neo4j
|
||||
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||
|
||||
return {
|
||||
spaceId: space.uuid,
|
||||
spaceName: space.name,
|
||||
spaceDescription: space.description as string,
|
||||
statementCount: currentStatementCount,
|
||||
contextCount: currentEpisodeCount,
|
||||
summary: summaryResult.summary,
|
||||
keyEntities: summaryResult.keyEntities || [],
|
||||
themes: summaryResult.themes,
|
||||
@ -400,38 +418,48 @@ function createUnifiedSummaryPrompt(
|
||||
return [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert at analyzing and summarizing structured knowledge within semantic spaces. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}.
|
||||
content: `You are an expert at analyzing and summarizing episodes within semantic spaces based on the space's intent and purpose. Your task is to ${isUpdate ? "update an existing summary by integrating new episodes" : "create a comprehensive summary of episodes"}.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Base your summary ONLY on insights derived from the actual content/episodes provided
|
||||
2. Use the space description only as contextual guidance, never copy or paraphrase it
|
||||
2. Use the space's INTENT/PURPOSE (from description) to guide what to summarize and how to organize it
|
||||
3. Write in a factual, neutral tone - avoid promotional language ("pivotal", "invaluable", "cutting-edge")
|
||||
4. Be specific and concrete - reference actual content, patterns, and themes found in the episodes
|
||||
4. Be specific and concrete - reference actual content, patterns, and insights found in the episodes
|
||||
5. If episodes are insufficient for meaningful insights, state that more data is needed
|
||||
|
||||
INTENT-DRIVEN SUMMARIZATION:
|
||||
Your summary should SERVE the space's intended purpose. Examples:
|
||||
- "Learning React" → Summarize React concepts, patterns, techniques learned
|
||||
- "Project X Updates" → Summarize progress, decisions, blockers, next steps
|
||||
- "Health Tracking" → Summarize metrics, trends, observations, insights
|
||||
- "Guidelines for React" → Extract actionable patterns, best practices, rules
|
||||
- "Evolution of design thinking" → Track how thinking changed over time, decision points
|
||||
The intent defines WHY this space exists - organize content to serve that purpose.
|
||||
|
||||
INSTRUCTIONS:
|
||||
${
|
||||
isUpdate
|
||||
? `1. Review the existing summary and themes carefully
|
||||
2. Analyze the new episodes for patterns and insights
|
||||
2. Analyze the new episodes for patterns and insights that align with the space's intent
|
||||
3. Identify connecting points between existing knowledge and new episodes
|
||||
4. Update the summary to seamlessly integrate new information while preserving valuable existing insights
|
||||
5. Evolve themes by adding new ones or refining existing ones based on connections found
|
||||
6. Update the markdown summary to reflect the enhanced themes and new insights`
|
||||
5. Evolve themes by adding new ones or refining existing ones based on the space's purpose
|
||||
6. Organize the summary to serve the space's intended use case`
|
||||
: `1. Analyze the semantic content and relationships within the episodes
|
||||
2. Identify the main themes and patterns across all episodes (themes must have at least 3 supporting episodes)
|
||||
3. Create a coherent summary that captures the essence of this knowledge domain
|
||||
4. Generate a well-structured markdown summary organized by the identified themes`
|
||||
2. Identify topics/sections that align with the space's INTENT and PURPOSE
|
||||
3. Create a coherent summary that serves the space's intended use case
|
||||
4. Organize the summary based on the space's purpose (not generic frequency-based themes)`
|
||||
}
|
||||
${isUpdate ? "7" : "6"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0)
|
||||
${isUpdate ? "7" : "5"}. Assess your confidence in the ${isUpdate ? "updated" : ""} summary quality (0.0-1.0)
|
||||
|
||||
THEME IDENTIFICATION RULES:
|
||||
- A theme must be supported by AT LEAST 3 related episodes to be considered valid
|
||||
- Themes should represent substantial, meaningful patterns rather than minor occurrences
|
||||
- Each theme must capture a distinct semantic domain or conceptual area
|
||||
- Only identify themes that have sufficient evidence in the data
|
||||
- If fewer than 3 episodes support a potential theme, do not include it
|
||||
- Themes will be used to organize the markdown summary into logical sections
|
||||
INTENT-ALIGNED ORGANIZATION:
|
||||
- Organize sections based on what serves the space's purpose
|
||||
- Topics don't need minimum episode counts - relevance to intent matters most
|
||||
- Each section should provide value aligned with the space's intended use
|
||||
- For "guidelines" spaces: focus on actionable patterns
|
||||
- For "tracking" spaces: focus on temporal patterns and changes
|
||||
- For "learning" spaces: focus on concepts and insights gained
|
||||
- Let the space's intent drive the structure, not rigid rules
|
||||
|
||||
${
|
||||
isUpdate
|
||||
@ -484,7 +512,7 @@ ${
|
||||
role: "user",
|
||||
content: `SPACE INFORMATION:
|
||||
Name: "${spaceName}"
|
||||
Description (for context only): ${spaceDescription || "No description provided"}
|
||||
Intent/Purpose: ${spaceDescription || "No specific intent provided - organize naturally based on content"}
|
||||
|
||||
${
|
||||
isUpdate
|
||||
@ -508,8 +536,8 @@ ${topEntities.join(", ")}`
|
||||
|
||||
${
|
||||
isUpdate
|
||||
? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Remember: only summarize insights from the actual episode content, not the space description."
|
||||
: "Please analyze the episodes and provide a comprehensive summary that captures insights derived from the episode content provided. Use the description only as context. If there are too few episodes to generate meaningful insights, indicate that more data is needed rather than falling back on the description."
|
||||
? "Please identify connections between the existing summary and new episodes, then update the summary to integrate the new insights coherently. Organize the summary to SERVE the space's intent/purpose. Remember: only summarize insights from the actual episode content."
|
||||
: "Please analyze the episodes and provide a comprehensive summary that SERVES the space's intent/purpose. Organize sections based on what would be most valuable for this space's intended use case. If the intent is unclear, organize naturally based on content patterns. Only summarize insights from actual episode content."
|
||||
}`,
|
||||
},
|
||||
];
|
||||
@ -519,7 +547,7 @@ async function getExistingSummary(spaceId: string): Promise<{
|
||||
summary: string;
|
||||
themes: string[];
|
||||
lastUpdated: Date;
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
const existingSummary = await getSpace(spaceId);
|
||||
@ -528,8 +556,8 @@ async function getExistingSummary(spaceId: string): Promise<{
|
||||
return {
|
||||
summary: existingSummary.summary,
|
||||
themes: existingSummary.themes,
|
||||
lastUpdated: existingSummary.lastPatternTrigger || new Date(),
|
||||
statementCount: existingSummary.statementCount || 0,
|
||||
lastUpdated: existingSummary.summaryGeneratedAt || new Date(),
|
||||
contextCount: existingSummary.contextCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -547,24 +575,18 @@ async function getSpaceEpisodes(
|
||||
userId: string,
|
||||
sinceDate?: Date,
|
||||
): Promise<SpaceEpisodeData[]> {
|
||||
// Build query to get distinct episodes that have statements in the space
|
||||
let whereClause =
|
||||
"s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL";
|
||||
// Query episodes directly using Space-[:HAS_EPISODE]->Episode relationships
|
||||
const params: any = { spaceId, userId };
|
||||
|
||||
// Store the sinceDate condition separately to apply after e is defined
|
||||
let dateCondition = "";
|
||||
if (sinceDate) {
|
||||
dateCondition = "e.createdAt > $sinceDate";
|
||||
dateCondition = "AND e.createdAt > $sinceDate";
|
||||
params.sinceDate = sinceDate.toISOString();
|
||||
}
|
||||
|
||||
const query = `
|
||||
MATCH (s:Statement{userId: $userId})
|
||||
WHERE ${whereClause}
|
||||
OPTIONAL MATCH (e:Episode{userId: $userId})-[:HAS_PROVENANCE]->(s)
|
||||
WITH e
|
||||
WHERE e IS NOT NULL ${dateCondition ? `AND ${dateCondition}` : ""}
|
||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||
WHERE e IS NOT NULL ${dateCondition}
|
||||
RETURN DISTINCT e
|
||||
ORDER BY e.createdAt DESC
|
||||
`;
|
||||
@ -654,7 +676,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
||||
space.keyEntities = $keyEntities,
|
||||
space.themes = $themes,
|
||||
space.summaryConfidence = $confidence,
|
||||
space.summaryStatementCount = $statementCount,
|
||||
space.summaryContextCount = $contextCount,
|
||||
space.summaryLastUpdated = datetime($lastUpdated)
|
||||
RETURN space
|
||||
`;
|
||||
@ -665,7 +687,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
||||
keyEntities: summaryData.keyEntities,
|
||||
themes: summaryData.themes,
|
||||
confidence: summaryData.confidence,
|
||||
statementCount: summaryData.statementCount,
|
||||
contextCount: summaryData.contextCount,
|
||||
lastUpdated: summaryData.lastUpdated.toISOString(),
|
||||
});
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ export const updateSpace = async (summaryData: {
|
||||
spaceId: string;
|
||||
summary: string;
|
||||
themes: string[];
|
||||
statementCount: number;
|
||||
contextCount: number;
|
||||
}) => {
|
||||
return await prisma.space.update({
|
||||
where: {
|
||||
@ -40,7 +40,8 @@ export const updateSpace = async (summaryData: {
|
||||
data: {
|
||||
summary: summaryData.summary,
|
||||
themes: summaryData.themes,
|
||||
statementCount: summaryData.statementCount,
|
||||
contextCount: summaryData.contextCount,
|
||||
summaryGeneratedAt: new Date().toISOString()
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -201,6 +201,45 @@ export class IntegrationLoader {
|
||||
return allTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools from a specific integration
|
||||
*/
|
||||
static async getIntegrationTools(sessionId: string, integrationSlug: string) {
|
||||
const integrationTransports =
|
||||
TransportManager.getSessionIntegrationTransports(sessionId);
|
||||
|
||||
if (integrationTransports.length === 0) {
|
||||
throw new Error(
|
||||
`No integration transports loaded for session ${sessionId}. Make sure integrations are connected and session is initialized properly.`,
|
||||
);
|
||||
}
|
||||
|
||||
const integrationTransport = integrationTransports.find(
|
||||
(t) => t.slug === integrationSlug,
|
||||
);
|
||||
|
||||
if (!integrationTransport) {
|
||||
const availableSlugs = integrationTransports
|
||||
.map((t) => t.slug)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Integration '${integrationSlug}' not found or not connected. Available integrations: ${availableSlugs}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await integrationTransport.client.listTools();
|
||||
|
||||
if (result.tools && Array.isArray(result.tools)) {
|
||||
return result.tools.map((tool: any) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || tool.name,
|
||||
inputSchema: tool.inputSchema,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on a specific integration
|
||||
*/
|
||||
|
||||
@ -3,6 +3,7 @@ import { addToQueue } from "~/lib/ingest.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { SearchService } from "~/services/search.server";
|
||||
import { SpaceService } from "~/services/space.server";
|
||||
import { IntegrationLoader } from "./integration-loader";
|
||||
|
||||
const searchService = new SearchService();
|
||||
const spaceService = new SpaceService();
|
||||
@ -13,29 +14,31 @@ const SearchParamsSchema = {
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "The search query in third person perspective",
|
||||
description:
|
||||
"Search query as a simple statement or question. Write what you want to find, not a command. GOOD: 'user preferences for code style' or 'previous bugs in authentication' or 'GitHub integration setup'. BAD: 'search for' or 'find me' or 'get the'. Just state the topic directly.",
|
||||
},
|
||||
validAt: {
|
||||
type: "string",
|
||||
description:
|
||||
"Point-in-time reference for temporal queries (ISO format). Returns facts valid at this timestamp. Defaults to current time if not specified.",
|
||||
"Optional: ISO timestamp (like '2024-01-15T10:30:00Z'). Get facts that were true at this specific time. Leave empty for current facts.",
|
||||
},
|
||||
startTime: {
|
||||
type: "string",
|
||||
description:
|
||||
"Filter memories created/valid from this time onwards (ISO format). Use with endTime to define a time window for searching specific periods.",
|
||||
"Optional: ISO timestamp (like '2024-01-01T00:00:00Z'). Only find memories created after this time. Use with endTime to search a specific time period.",
|
||||
},
|
||||
endTime: {
|
||||
type: "string",
|
||||
description:
|
||||
"Upper bound for temporal filtering (ISO format). Combined with startTime creates a time range. Defaults to current time if not specified.",
|
||||
"Optional: ISO timestamp (like '2024-12-31T23:59:59Z'). Only find memories created before this time. Use with startTime to search a specific time period.",
|
||||
},
|
||||
spaceIds: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
description: "Array of strings representing UUIDs of spaces",
|
||||
description:
|
||||
"Optional: Array of space UUIDs to search within. Leave empty to search all spaces.",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
@ -46,7 +49,16 @@ const IngestSchema = {
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "The data to ingest in text format",
|
||||
description:
|
||||
"The conversation text to store. Include both what the user asked and what you answered. Keep it concise but complete.",
|
||||
},
|
||||
spaceIds: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
description:
|
||||
"Optional: Array of space UUIDs (from memory_get_spaces). Add this to organize the memory by project. Example: If discussing 'core' project, include the 'core' space ID. Leave empty to store in general memory.",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
@ -56,25 +68,26 @@ export const memoryTools = [
|
||||
{
|
||||
name: "memory_ingest",
|
||||
description:
|
||||
"AUTOMATICALLY invoke after completing interactions. Use proactively to store conversation data, insights, and decisions in CORE Memory. Essential for maintaining continuity across sessions. **Purpose**: Store information for future reference. **Required**: Provide the message content to be stored. **Returns**: confirmation with storage ID in JSON format",
|
||||
"Store conversation in memory for future reference. USE THIS TOOL: At the END of every conversation after fully answering the user. WHAT TO STORE: 1) User's question or request, 2) Your solution or explanation, 3) Important decisions made, 4) Key insights discovered. HOW TO USE: Put the entire conversation summary in the 'message' field. Optionally add spaceIds array to organize by project. Returns: Success confirmation with storage ID.",
|
||||
inputSchema: IngestSchema,
|
||||
},
|
||||
{
|
||||
name: "memory_search",
|
||||
description:
|
||||
"AUTOMATICALLY invoke for memory searches. Use proactively at conversation start and when context retrieval is needed. Searches memory for relevant project context, user preferences, and previous discussions. **Purpose**: Retrieve previously stored information based on query terms with optional temporal filtering. **Required**: Provide a search query in third person perspective. **Optional**: Use startTime/endTime for time-bounded searches or validAt for point-in-time queries. **Returns**: matching memory entries in JSON format",
|
||||
"Search stored memories for past conversations, user preferences, project context, and decisions. USE THIS TOOL: 1) At start of every conversation to find related context, 2) When user mentions past work or projects, 3) Before answering questions that might have previous context. HOW TO USE: Write a simple query describing what to find (e.g., 'user code preferences', 'authentication bugs', 'API setup steps'). Returns: Episodes (past conversations) and Facts (extracted knowledge) as JSON.",
|
||||
inputSchema: SearchParamsSchema,
|
||||
},
|
||||
{
|
||||
name: "memory_get_spaces",
|
||||
description:
|
||||
"Get available memory spaces. **Purpose**: Retrieve list of memory organization spaces. **Required**: No required parameters. **Returns**: list of available spaces in JSON format",
|
||||
"List all available memory spaces (project contexts). USE THIS TOOL: To see what spaces exist before searching or storing memories. Each space organizes memories by topic (e.g., 'Profile' for user info, 'GitHub' for GitHub work, project names for project-specific context). Returns: Array of spaces with id, name, and description.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
all: {
|
||||
type: "boolean",
|
||||
description: "Get all spaces",
|
||||
description:
|
||||
"Set to true to get all spaces including system spaces. Leave empty for user spaces only.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -82,17 +95,89 @@ export const memoryTools = [
|
||||
{
|
||||
name: "memory_about_user",
|
||||
description:
|
||||
"Get information about the user. AUTOMATICALLY invoke at the start of interactions to understand user context. Returns the user's background, preferences, work, interests, and other personal information. **Required**: No required parameters. **Returns**: User information as text.",
|
||||
"Get user's profile information (background, preferences, work, interests). USE THIS TOOL: At the start of conversations to understand who you're helping. This provides context about the user's technical preferences, work style, and personal details. Returns: User profile summary as text.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "boolean",
|
||||
description: "Get user profile",
|
||||
description:
|
||||
"Set to true to get full profile. Leave empty for default profile view.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "memory_get_space",
|
||||
description:
|
||||
"Get detailed information about a specific space including its full summary. USE THIS TOOL: When working on a project to get comprehensive context about that project. The summary contains consolidated knowledge about the space topic. HOW TO USE: Provide either spaceName (like 'core', 'GitHub', 'Profile') OR spaceId (UUID). Returns: Space details with full summary, description, and metadata.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
spaceId: {
|
||||
type: "string",
|
||||
description:
|
||||
"UUID of the space (use this if you have the ID from memory_get_spaces)",
|
||||
},
|
||||
spaceName: {
|
||||
type: "string",
|
||||
description:
|
||||
"Name of the space (easier option). Examples: 'core', 'Profile', 'GitHub', 'Health'",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_integrations",
|
||||
description:
|
||||
"List all connected integrations (GitHub, Linear, Slack, etc.). USE THIS TOOL: Before using integration actions to see what's available. WORKFLOW: 1) Call this to see available integrations, 2) Call get_integration_actions with a slug to see what you can do, 3) Call execute_integration_action to do it. Returns: Array with slug, name, accountId, and hasMcp for each integration.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_integration_actions",
|
||||
description:
|
||||
"Get list of actions available for a specific integration. USE THIS TOOL: After get_integrations to see what operations you can perform. For example, GitHub integration has actions like 'get_pr', 'get_issues', 'create_issue'. HOW TO USE: Provide the integrationSlug from get_integrations (like 'github', 'linear', 'slack'). Returns: Array of actions with name, description, and inputSchema for each.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
integrationSlug: {
|
||||
type: "string",
|
||||
description:
|
||||
"Slug from get_integrations. Examples: 'github', 'linear', 'slack'",
|
||||
},
|
||||
},
|
||||
required: ["integrationSlug"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execute_integration_action",
|
||||
description:
|
||||
"Execute an action on an integration (fetch GitHub PR, create Linear issue, send Slack message, etc.). USE THIS TOOL: After using get_integration_actions to see available actions. HOW TO USE: 1) Set integrationSlug (like 'github'), 2) Set action name (like 'get_pr'), 3) Set arguments object with required parameters from the action's inputSchema. Returns: Result of the action execution.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
integrationSlug: {
|
||||
type: "string",
|
||||
description:
|
||||
"Slug from get_integrations. Examples: 'github', 'linear', 'slack'",
|
||||
},
|
||||
action: {
|
||||
type: "string",
|
||||
description:
|
||||
"Action name from get_integration_actions. Examples: 'get_pr', 'get_issues', 'create_issue'",
|
||||
},
|
||||
arguments: {
|
||||
type: "object",
|
||||
description:
|
||||
"Parameters for the action. Check the action's inputSchema from get_integration_actions to see what's required.",
|
||||
},
|
||||
},
|
||||
required: ["integrationSlug", "action"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Function to call memory tools based on toolName
|
||||
@ -112,6 +197,14 @@ export async function callMemoryTool(
|
||||
return await handleMemoryGetSpaces(userId);
|
||||
case "memory_about_user":
|
||||
return await handleUserProfile(userId);
|
||||
case "memory_get_space":
|
||||
return await handleGetSpace({ ...args, userId });
|
||||
case "get_integrations":
|
||||
return await handleGetIntegrations({ ...args, userId });
|
||||
case "get_integration_actions":
|
||||
return await handleGetIntegrationActions({ ...args });
|
||||
case "execute_integration_action":
|
||||
return await handleExecuteIntegrationAction({ ...args });
|
||||
default:
|
||||
throw new Error(`Unknown memory tool: ${toolName}`);
|
||||
}
|
||||
@ -160,12 +253,17 @@ async function handleUserProfile(userId: string) {
|
||||
// Handler for memory_ingest
|
||||
async function handleMemoryIngest(args: any) {
|
||||
try {
|
||||
// Use spaceIds from args if provided, otherwise use spaceId from query params
|
||||
const spaceIds =
|
||||
args.spaceIds || (args.spaceId ? [args.spaceId] : undefined);
|
||||
|
||||
const response = await addToQueue(
|
||||
{
|
||||
episodeBody: args.message,
|
||||
referenceTime: new Date().toISOString(),
|
||||
source: args.source,
|
||||
type: EpisodeTypeEnum.CONVERSATION,
|
||||
spaceIds,
|
||||
},
|
||||
args.userId,
|
||||
);
|
||||
@ -198,12 +296,17 @@ async function handleMemoryIngest(args: any) {
|
||||
// Handler for memory_search
|
||||
async function handleMemorySearch(args: any) {
|
||||
try {
|
||||
// Use spaceIds from args if provided, otherwise use spaceId from query params
|
||||
const spaceIds =
|
||||
args.spaceIds || (args.spaceId ? [args.spaceId] : undefined);
|
||||
|
||||
const results = await searchService.search(
|
||||
args.query,
|
||||
args.userId,
|
||||
{
|
||||
startTime: args.startTime ? new Date(args.startTime) : undefined,
|
||||
endTime: args.endTime ? new Date(args.endTime) : undefined,
|
||||
spaceIds,
|
||||
},
|
||||
args.source,
|
||||
);
|
||||
@ -235,11 +338,17 @@ async function handleMemoryGetSpaces(userId: string) {
|
||||
try {
|
||||
const spaces = await spaceService.getUserSpaces(userId);
|
||||
|
||||
// Return id, name, and description for listing
|
||||
const simplifiedSpaces = spaces.map((space) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(spaces),
|
||||
text: JSON.stringify(simplifiedSpaces),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
@ -258,3 +367,182 @@ async function handleMemoryGetSpaces(userId: string) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for memory_get_space
|
||||
async function handleGetSpace(args: any) {
|
||||
try {
|
||||
const { spaceId, spaceName, userId } = args;
|
||||
|
||||
if (!spaceId && !spaceName) {
|
||||
throw new Error("Either spaceId or spaceName is required");
|
||||
}
|
||||
|
||||
let space;
|
||||
if (spaceName) {
|
||||
space = await spaceService.getSpaceByName(spaceName, userId);
|
||||
} else {
|
||||
space = await spaceService.getSpace(spaceId, userId);
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
throw new Error(`Space not found: ${spaceName || spaceId}`);
|
||||
}
|
||||
|
||||
// Return id, name, description, and summary for detailed view
|
||||
const spaceDetails = {
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
summary: space.summary,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(spaceDetails),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`MCP get space error: ${error}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error getting space: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for get_integrations
|
||||
async function handleGetIntegrations(args: any) {
|
||||
try {
|
||||
const { userId, workspaceId } = args;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new Error("workspaceId is required");
|
||||
}
|
||||
|
||||
const integrations =
|
||||
await IntegrationLoader.getConnectedIntegrationAccounts(
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const simplifiedIntegrations = integrations.map((account) => ({
|
||||
slug: account.integrationDefinition.slug,
|
||||
name: account.integrationDefinition.name,
|
||||
accountId: account.id,
|
||||
hasMcp: !!account.integrationDefinition.spec?.mcp,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(simplifiedIntegrations),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`MCP get integrations error: ${error}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error getting integrations: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for get_integration_actions
|
||||
async function handleGetIntegrationActions(args: any) {
|
||||
try {
|
||||
const { integrationSlug, sessionId } = args;
|
||||
|
||||
if (!integrationSlug) {
|
||||
throw new Error("integrationSlug is required");
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
|
||||
const tools = await IntegrationLoader.getIntegrationTools(
|
||||
sessionId,
|
||||
integrationSlug,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(tools),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`MCP get integration actions error: ${error}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error getting integration actions: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for execute_integration_action
|
||||
async function handleExecuteIntegrationAction(args: any) {
|
||||
try {
|
||||
const { integrationSlug, action, arguments: actionArgs, sessionId } = args;
|
||||
|
||||
if (!integrationSlug) {
|
||||
throw new Error("integrationSlug is required");
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
throw new Error("action is required");
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error("sessionId is required");
|
||||
}
|
||||
|
||||
const toolName = `${integrationSlug}_${action}`;
|
||||
const result = await IntegrationLoader.callIntegrationTool(
|
||||
sessionId,
|
||||
toolName,
|
||||
actionArgs || {},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`MCP execute integration action error: ${error}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error executing integration action: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
VERSION=0.1.23
|
||||
VERSION=0.1.24
|
||||
|
||||
# Nest run in docker, change host to database container name
|
||||
DB_HOST=postgres
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "core",
|
||||
"private": true,
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `statementCount` on the `Space` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `statementCountAtLastTrigger` on the `Space` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Space" DROP COLUMN "statementCount",
|
||||
DROP COLUMN "statementCountAtLastTrigger",
|
||||
ADD COLUMN "contextCount" INTEGER,
|
||||
ADD COLUMN "contextCountAtLastTrigger" INTEGER;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Space" ADD COLUMN "summaryGeneratedAt" TIMESTAMP(3);
|
||||
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `spaceId` on the `IngestionQueue` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "IngestionQueue" DROP CONSTRAINT "IngestionQueue_spaceId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "IngestionQueue" DROP COLUMN "spaceId";
|
||||
@ -113,10 +113,6 @@ model ConversationHistory {
|
||||
model IngestionQueue {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relations
|
||||
space Space? @relation(fields: [spaceId], references: [id])
|
||||
spaceId String?
|
||||
|
||||
// Queue metadata
|
||||
data Json // The actual data to be processed
|
||||
output Json? // The processed output data
|
||||
@ -472,20 +468,21 @@ model RecallLog {
|
||||
}
|
||||
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
summary String?
|
||||
themes String[]
|
||||
statementCount Int?
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
summary String?
|
||||
themes String[]
|
||||
contextCount Int? // Count of context items in this space (episodes, statements, etc.)
|
||||
|
||||
status String?
|
||||
|
||||
icon String?
|
||||
|
||||
lastPatternTrigger DateTime?
|
||||
statementCountAtLastTrigger Int?
|
||||
lastPatternTrigger DateTime?
|
||||
summaryGeneratedAt DateTime?
|
||||
contextCountAtLastTrigger Int? // Context count when pattern was last triggered
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
@ -493,7 +490,6 @@ model Space {
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
IngestionQueue IngestionQueue[]
|
||||
SpacePattern SpacePattern[]
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export interface EpisodicNode {
|
||||
sessionId?: string;
|
||||
recallCount?: number;
|
||||
chunkIndex?: number; // Index of this chunk within the document
|
||||
spaceIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,7 +6,7 @@ export interface SpaceNode {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isActive: boolean;
|
||||
statementCount?: number; // Computed field
|
||||
contextCount?: number; // Computed field - count of episodes assigned to this space
|
||||
embedding?: number[]; // For future space similarity
|
||||
}
|
||||
|
||||
|
||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user