mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 09:58:28 +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
|
# Nest run in docker, change host to database container name
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const EnvironmentSchema = z.object({
|
const EnvironmentSchema = z.object({
|
||||||
// Version
|
// Version
|
||||||
VERSION: z.string().default("0.1.14"),
|
VERSION: z.string().default("0.1.24"),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DB_HOST: z.string().default("localhost"),
|
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 TableRow from "@tiptap/extension-table-row";
|
||||||
import { all, createLowlight } from "lowlight";
|
import { all, createLowlight } from "lowlight";
|
||||||
import { mergeAttributes, type Extension } from "@tiptap/react";
|
import { mergeAttributes, type Extension } from "@tiptap/react";
|
||||||
|
import { Markdown } from "tiptap-markdown";
|
||||||
|
|
||||||
// create a lowlight instance with all languages loaded
|
// create a lowlight instance with all languages loaded
|
||||||
export const lowlight = createLowlight(all);
|
export const lowlight = createLowlight(all);
|
||||||
@ -136,4 +137,5 @@ export const extensionsForConversation = [
|
|||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight,
|
lowlight,
|
||||||
}),
|
}),
|
||||||
|
Markdown,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -83,10 +83,14 @@ export const GraphClusteringVisualization = forwardRef<
|
|||||||
filtered = filtered.filter((triplet) => {
|
filtered = filtered.filter((triplet) => {
|
||||||
const sourceMatches =
|
const sourceMatches =
|
||||||
isEpisodeNode(triplet.sourceNode) &&
|
isEpisodeNode(triplet.sourceNode) &&
|
||||||
triplet.sourceNode.attributes?.content?.toLowerCase().includes(query);
|
triplet.sourceNode.attributes?.content
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(query);
|
||||||
const targetMatches =
|
const targetMatches =
|
||||||
isEpisodeNode(triplet.targetNode) &&
|
isEpisodeNode(triplet.targetNode) &&
|
||||||
triplet.targetNode.attributes?.content?.toLowerCase().includes(query);
|
triplet.targetNode.attributes?.content
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(query);
|
||||||
|
|
||||||
return sourceMatches || targetMatches;
|
return sourceMatches || targetMatches;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, type ReactNode } from "react";
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
import { useFetcher } from "@remix-run/react";
|
import { useFetcher } from "@remix-run/react";
|
||||||
import { AlertCircle, Loader2 } from "lucide-react";
|
import { AlertCircle, File, Loader2, MessageSquare } from "lucide-react";
|
||||||
import { Badge, BadgeColor } from "../ui/badge";
|
import { Badge, BadgeColor } from "../ui/badge";
|
||||||
import { type LogItem } from "~/hooks/use-logs";
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
@ -8,6 +8,7 @@ import { getIconForAuthorise } from "../icon-utils";
|
|||||||
import { cn, formatString } from "~/lib/utils";
|
import { cn, formatString } from "~/lib/utils";
|
||||||
import { getStatusColor } from "./utils";
|
import { getStatusColor } from "./utils";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { SpaceDropdown } from "../spaces/space-dropdown";
|
||||||
|
|
||||||
interface LogDetailsProps {
|
interface LogDetailsProps {
|
||||||
log: LogItem;
|
log: LogItem;
|
||||||
@ -33,13 +34,13 @@ function PropertyItem({
|
|||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center py-1">
|
<div className="flex items-center py-1 !text-base">
|
||||||
<span className="text-muted-foreground min-w-[160px]">{label}</span>
|
<span className="text-muted-foreground min-w-[120px]">{label}</span>
|
||||||
|
|
||||||
{variant === "status" ? (
|
{variant === "status" ? (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"!bg-grayAlpha-100 text-muted-foreground h-7 rounded px-4 text-xs",
|
"text-foreground h-7 items-center gap-2 rounded !bg-transparent px-4.5 !text-base",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -49,7 +50,13 @@ function PropertyItem({
|
|||||||
{value}
|
{value}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant={variant} className={cn("h-7 rounded px-4", className)}>
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"h-7 items-center gap-2 rounded bg-transparent px-4 !text-base",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{value}
|
{value}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -73,10 +80,10 @@ interface EpisodeFactsResponse {
|
|||||||
|
|
||||||
function getStatusValue(status: string) {
|
function getStatusValue(status: string) {
|
||||||
if (status === "PENDING") {
|
if (status === "PENDING") {
|
||||||
return "In Queue";
|
return formatString("IN QUEUE");
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return formatString(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogDetails({ log }: LogDetailsProps) {
|
export function LogDetails({ log }: LogDetailsProps) {
|
||||||
@ -113,6 +120,9 @@ export function LogDetails({ log }: LogDetailsProps) {
|
|||||||
} else if (log.episodeUUID) {
|
} else if (log.episodeUUID) {
|
||||||
setFactsLoading(true);
|
setFactsLoading(true);
|
||||||
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
|
fetcher.load(`/api/v1/episodes/${log.episodeUUID}/facts`);
|
||||||
|
} else {
|
||||||
|
setFacts([]);
|
||||||
|
setInvalidFacts([]);
|
||||||
}
|
}
|
||||||
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
|
}, [log.episodeUUID, log.data?.type, log.data?.episodes, facts.length]);
|
||||||
|
|
||||||
@ -129,41 +139,8 @@ export function LogDetails({ log }: LogDetailsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center overflow-auto">
|
<div className="flex h-full w-full flex-col items-center overflow-auto">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="px-4 pt-4">
|
<div className="mt-5 mb-5 px-4">
|
||||||
<div className="mb-4 flex w-full items-center justify-between">
|
|
||||||
<span>Episode Details</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-10 px-4">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{log.data?.type === "DOCUMENT" && log.data?.episodes ? (
|
|
||||||
<PropertyItem
|
|
||||||
label="Episodes"
|
|
||||||
value={
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{log.data.episodes.map(
|
|
||||||
(episodeId: string, index: number) => (
|
|
||||||
<Badge
|
|
||||||
key={index}
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{episodeId}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PropertyItem
|
|
||||||
label="Episode Id"
|
|
||||||
value={log.episodeUUID}
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<PropertyItem
|
<PropertyItem
|
||||||
label="Session Id"
|
label="Session Id"
|
||||||
value={log.data?.sessionId?.toLowerCase()}
|
value={log.data?.sessionId?.toLowerCase()}
|
||||||
@ -174,6 +151,13 @@ export function LogDetails({ log }: LogDetailsProps) {
|
|||||||
value={formatString(
|
value={formatString(
|
||||||
log.data?.type ? log.data.type.toLowerCase() : "conversation",
|
log.data?.type ? log.data.type.toLowerCase() : "conversation",
|
||||||
)}
|
)}
|
||||||
|
icon={
|
||||||
|
log.data?.type === "CONVERSATION" ? (
|
||||||
|
<MessageSquare size={16} />
|
||||||
|
) : (
|
||||||
|
<File size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
/>
|
/>
|
||||||
<PropertyItem
|
<PropertyItem
|
||||||
@ -192,15 +176,28 @@ export function LogDetails({ log }: LogDetailsProps) {
|
|||||||
variant="status"
|
variant="status"
|
||||||
statusColor={log.status && getStatusColor(log.status)}
|
statusColor={log.status && getStatusColor(log.status)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Space Assignment for CONVERSATION type */}
|
||||||
|
{log.data.type.toLowerCase() === "conversation" &&
|
||||||
|
log?.episodeUUID && (
|
||||||
|
<div className="mt-2 flex items-start py-1">
|
||||||
|
<span className="text-muted-foreground min-w-[120px]">
|
||||||
|
Spaces
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SpaceDropdown
|
||||||
|
className="px-3"
|
||||||
|
episodeIds={[log.episodeUUID]}
|
||||||
|
selectedSpaceIds={log.spaceIds || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Details */}
|
{/* Error Details */}
|
||||||
{log.error && (
|
{log.error && (
|
||||||
<div className="mb-6 px-4">
|
<div className="mb-6 px-4">
|
||||||
<div className="mb-2 flex w-full items-center justify-between">
|
|
||||||
<span>Error Details</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-destructive/10 rounded-md p-3">
|
<div className="bg-destructive/10 rounded-md p-3">
|
||||||
<div className="flex items-start gap-2 text-red-600">
|
<div className="flex items-start gap-2 text-red-600">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
@ -212,21 +209,63 @@ export function LogDetails({ log }: LogDetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col items-center p-4 pt-0">
|
{log.data?.type === "CONVERSATION" && (
|
||||||
<div className="mb-2 flex w-full items-center justify-between">
|
<div className="flex flex-col items-center p-4 pt-0">
|
||||||
<span>Content</span>
|
{/* Log Content */}
|
||||||
</div>
|
<div className="mb-4 w-full break-words whitespace-pre-wrap">
|
||||||
{/* Log Content */}
|
<div className="rounded-md">
|
||||||
<div className="mb-4 w-full text-sm break-words whitespace-pre-wrap">
|
<Markdown>{log.ingestText}</Markdown>
|
||||||
<div className="rounded-md">
|
</div>
|
||||||
<Markdown>{log.ingestText}</Markdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Episodes List for DOCUMENT type */}
|
||||||
|
{log.data?.type === "DOCUMENT" && log.episodeDetails?.length > 0 && (
|
||||||
|
<div className="mb-6 px-4">
|
||||||
|
<div className="mb-2 flex w-full items-center justify-between font-medium">
|
||||||
|
<span>Episodes ({log.episodeDetails.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{log.episodeDetails.map((episode: any, index: number) => (
|
||||||
|
<div
|
||||||
|
key={episode.uuid}
|
||||||
|
className="bg-grayAlpha-100 flex flex-col gap-3 rounded-md p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Episode {index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-mono text-xs">
|
||||||
|
{episode.uuid}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<SpaceDropdown
|
||||||
|
episodeIds={[episode.uuid]}
|
||||||
|
selectedSpaceIds={episode.spaceIds || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Episode Content */}
|
||||||
|
<div className="border-grayAlpha-200 border-t pt-3">
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs">
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
<div className="text-sm break-words whitespace-pre-wrap">
|
||||||
|
<Markdown>{episode.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Episode Facts */}
|
{/* Episode Facts */}
|
||||||
<div className="mb-6 px-4">
|
<div className="mb-6 px-4">
|
||||||
<div className="mb-2 flex w-full items-center justify-between">
|
<div className="mb-2 flex w-full items-center justify-between font-medium">
|
||||||
<span>Facts</span>
|
<span>Facts</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { EllipsisVertical, Trash } from "lucide-react";
|
import { EllipsisVertical, Trash, Copy } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
} from "../ui/alert-dialog";
|
} from "../ui/alert-dialog";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||||
|
import { toast } from "~/hooks/use-toast";
|
||||||
|
|
||||||
interface LogOptionsProps {
|
interface LogOptionsProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -40,8 +41,24 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
|
|||||||
setDeleteDialogOpen(false);
|
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(() => {
|
useEffect(() => {
|
||||||
console.log(deleteFetcher.state, deleteFetcher.data);
|
|
||||||
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
|
if (deleteFetcher.state === "idle" && deleteFetcher.data?.success) {
|
||||||
navigate(`/home/inbox`);
|
navigate(`/home/inbox`);
|
||||||
}
|
}
|
||||||
@ -49,16 +66,26 @@ export const LogOptions = ({ id }: LogOptionsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="secondary"
|
||||||
className="gap-2 rounded"
|
size="sm"
|
||||||
onClick={(e) => {
|
className="gap-2 rounded"
|
||||||
setDeleteDialogOpen(true);
|
onClick={handleCopy}
|
||||||
}}
|
>
|
||||||
>
|
<Copy size={15} /> Copy ID
|
||||||
<Trash size={15} /> Delete
|
</Button>
|
||||||
</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}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { type LogItem } from "~/hooks/use-logs";
|
|||||||
import { getIconForAuthorise } from "../icon-utils";
|
import { getIconForAuthorise } from "../icon-utils";
|
||||||
import { useNavigate, useParams } from "@remix-run/react";
|
import { useNavigate, useParams } from "@remix-run/react";
|
||||||
import { getStatusColor, getStatusValue } from "./utils";
|
import { getStatusColor, getStatusValue } from "./utils";
|
||||||
|
import { File, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
interface LogTextCollapseProps {
|
interface LogTextCollapseProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
@ -49,9 +50,13 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getIngestType = (log: LogItem) => {
|
const getIngestType = (log: LogItem) => {
|
||||||
const type = log.type ?? log.data.type ?? "Conversation";
|
const type = log.type ?? log.data.type ?? "CONVERSATION";
|
||||||
|
|
||||||
return type[0].toUpperCase();
|
return type === "CONVERSATION" ? (
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
) : (
|
||||||
|
<File size={14} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -100,7 +105,7 @@ export function LogTextCollapse({ text, log }: LogTextCollapseProps) {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"!bg-grayAlpha-100 text-muted-foreground rounded text-xs",
|
"text-muted-foreground rounded !bg-transparent text-xs",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getIngestType(log)}
|
{getIngestType(log)}
|
||||||
|
|||||||
@ -22,5 +22,5 @@ export function getStatusValue(status: string) {
|
|||||||
return formatString("In Queue");
|
return formatString("In Queue");
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return formatString(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
import { type LogItem } from "~/hooks/use-logs";
|
import { type LogItem } from "~/hooks/use-logs";
|
||||||
import { ScrollManagedList } from "../virtualized-list";
|
import { ScrollManagedList } from "../virtualized-list";
|
||||||
import { LogTextCollapse } from "./log-text-collapse";
|
import { LogTextCollapse } from "./log-text-collapse";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
interface VirtualLogsListProps {
|
interface VirtualLogsListProps {
|
||||||
logs: LogItem[];
|
logs: LogItem[];
|
||||||
@ -139,7 +140,7 @@ export function VirtualLogsList({
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="text-muted-foreground p-4 text-center text-sm">
|
<div className="text-muted-foreground p-4 text-center text-sm">
|
||||||
Loading more logs...
|
<LoaderCircle size={18} className="mr-1 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -139,6 +139,7 @@ export default function OnboardingQuestionComponent({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xl"
|
size="xl"
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
|
disabled={loading}
|
||||||
className="rounded-lg px-4 py-2"
|
className="rounded-lg px-4 py-2"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
@ -151,7 +152,7 @@ export default function OnboardingQuestionComponent({
|
|||||||
size="xl"
|
size="xl"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
isLoading={!!loading}
|
isLoading={!!loading}
|
||||||
disabled={!isValid()}
|
disabled={!isValid() || loading}
|
||||||
className="rounded-lg px-4 py-2"
|
className="rounded-lg px-4 py-2"
|
||||||
>
|
>
|
||||||
{isLast ? "Complete Profile" : "Continue"}
|
{isLast ? "Complete Profile" : "Continue"}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -12,14 +13,20 @@ import {
|
|||||||
Columns3,
|
Columns3,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
LoaderCircle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Network,
|
Network,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NavMain } from "./nav-main";
|
import { NavMain } from "./nav-main";
|
||||||
import { useUser } from "~/hooks/useUser";
|
import { useUser } from "~/hooks/useUser";
|
||||||
import { NavUser } from "./nav-user";
|
import { NavUser } from "./nav-user";
|
||||||
import Logo from "../logo/logo";
|
import Logo from "../logo/logo";
|
||||||
import { ConversationList } from "../conversation";
|
import { ConversationList } from "../conversation";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
import { Project } from "../icons/project";
|
||||||
|
import { AddMemoryCommand } from "../command-bar/add-memory-command";
|
||||||
|
import { AddMemoryDialog } from "../command-bar/memory-dialog.client";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
@ -41,7 +48,7 @@ const data = {
|
|||||||
{
|
{
|
||||||
title: "Spaces",
|
title: "Spaces",
|
||||||
url: "/home/space",
|
url: "/home/space",
|
||||||
icon: Columns3,
|
icon: Project,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Integrations",
|
title: "Integrations",
|
||||||
@ -54,33 +61,57 @@ const data = {
|
|||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
|
||||||
return (
|
const [showAddMemory, setShowAddMemory] = React.useState(false);
|
||||||
<Sidebar
|
|
||||||
variant="inset"
|
|
||||||
{...props}
|
|
||||||
className="bg-background h-[100vh] py-2"
|
|
||||||
>
|
|
||||||
<SidebarHeader>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
|
|
||||||
<Logo size={20} />
|
|
||||||
C.O.R.E.
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
<NavMain items={data.navMain} />
|
|
||||||
<div className="mt-4 flex h-full flex-col">
|
|
||||||
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
|
|
||||||
<ConversationList />
|
|
||||||
</div>
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter className="px-2">
|
// Open command bar with Meta+K (Cmd+K on Mac, Ctrl+K on Windows/Linux)
|
||||||
<NavUser user={user} />
|
useHotkeys("meta+k", (e) => {
|
||||||
</SidebarFooter>
|
e.preventDefault();
|
||||||
</Sidebar>
|
setShowAddMemory(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sidebar
|
||||||
|
variant="inset"
|
||||||
|
{...props}
|
||||||
|
className="bg-background h-[100vh] py-2"
|
||||||
|
>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem className="flex justify-center">
|
||||||
|
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
|
||||||
|
<Logo size={20} />
|
||||||
|
C.O.R.E.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
isActive
|
||||||
|
size="sm"
|
||||||
|
className="rounded"
|
||||||
|
onClick={() => setShowAddMemory(true)}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</Button>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={data.navMain} />
|
||||||
|
<div className="mt-4 flex h-full flex-col">
|
||||||
|
<h2 className="text-muted-foreground px-4 text-sm"> History </h2>
|
||||||
|
<ConversationList />
|
||||||
|
</div>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter className="flex flex-col px-2">
|
||||||
|
<NavUser user={user} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
{showAddMemory && (
|
||||||
|
<AddMemoryDialog open={showAddMemory} onOpenChange={setShowAddMemory} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,15 @@ export function NavUser({ user }: { user: ExtendedUser }) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/settings/billing");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>{user.availableCredits} credits</div>
|
||||||
|
</Button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,8 +17,8 @@ interface SpaceCardProps {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
autoMode: boolean;
|
autoMode: boolean;
|
||||||
statementCount: number | null;
|
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
contextCount?: number | null;
|
||||||
themes?: string[];
|
themes?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -46,13 +46,17 @@ export function SpaceCard({ space }: SpaceCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-base">{space.name}</CardTitle>
|
<CardTitle className="text-base">{space.name}</CardTitle>
|
||||||
<CardDescription className="line-clamp-2 text-xs">
|
<CardDescription className="line-clamp-2 text-xs">
|
||||||
{space.description || space.summary || "Knowledge space"}
|
<p
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: space.description || space.summary || "Knowledge space",
|
||||||
|
}}
|
||||||
|
></p>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
||||||
{space.statementCount && space.statementCount > 0 && (
|
{space.contextCount && space.contextCount > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{space.statementCount} fact
|
{space.contextCount} episode
|
||||||
{space.statementCount !== 1 ? "s" : ""}
|
{space.contextCount !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { Badge } from "~/components/ui/badge";
|
||||||
import type { StatementNode } from "@core/types";
|
import type { StatementNode } from "@core/types";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { useNavigate } from "@remix-run/react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
|
||||||
interface SpaceFactCardProps {
|
export interface Episode {
|
||||||
fact: StatementNode;
|
uuid: string;
|
||||||
|
content: string;
|
||||||
|
originalContent: string;
|
||||||
|
source: any;
|
||||||
|
createdAt: Date;
|
||||||
|
validAt: Date;
|
||||||
|
metadata: any;
|
||||||
|
sessionId: any;
|
||||||
|
logId?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpaceFactCard({ fact }: SpaceFactCardProps) {
|
interface SpaceFactCardProps {
|
||||||
|
episode: Episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpaceEpisodeCard({ episode }: SpaceFactCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const formatDate = (date: Date | string) => {
|
const formatDate = (date: Date | string) => {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
return d.toLocaleDateString("en-US", {
|
return d.toLocaleDateString("en-US", {
|
||||||
@ -17,18 +32,20 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayText = fact.fact;
|
const displayText = episode.originalContent;
|
||||||
|
|
||||||
const recallCount =
|
const onClick = () => {
|
||||||
(fact.recallCount?.high ?? 0) + (fact.recallCount?.low ?? 0);
|
navigate(`/home/inbox/${episode.logId}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full items-center px-5 pr-2">
|
<div className="group flex w-full items-center px-5 pr-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow items-start gap-2 rounded-md px-3",
|
"group-hover:bg-grayAlpha-100 flex min-w-[0px] shrink grow cursor-pointer items-start gap-2 rounded-md px-3",
|
||||||
)}
|
)}
|
||||||
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -37,19 +54,13 @@ export function SpaceFactCard({ fact }: SpaceFactCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
<div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start">
|
<div className="inline-flex min-h-[24px] min-w-[0px] shrink items-center justify-start">
|
||||||
<div className={cn("truncate text-left")}>{displayText}</div>
|
<Markdown>{displayText}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs">
|
<div className="text-muted-foreground flex shrink-0 items-center justify-end gap-2 text-xs">
|
||||||
{!!recallCount && <span>Recalled: {recallCount} times</span>}
|
|
||||||
<Badge variant="secondary" className="rounded text-xs">
|
<Badge variant="secondary" className="rounded text-xs">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
{formatDate(fact.validAt)}
|
{formatDate(episode.validAt)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{fact.invalidAt && (
|
|
||||||
<Badge variant="destructive" className="rounded text-xs">
|
|
||||||
Invalid since {formatDate(fact.invalidAt)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from "~/components/ui/popover";
|
} from "~/components/ui/popover";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
interface SpaceFactsFiltersProps {
|
interface SpaceEpisodesFiltersProps {
|
||||||
selectedValidDate?: string;
|
selectedValidDate?: string;
|
||||||
selectedSpaceFilter?: string;
|
selectedSpaceFilter?: string;
|
||||||
onValidDateChange: (date?: string) => void;
|
onValidDateChange: (date?: string) => void;
|
||||||
@ -22,34 +22,24 @@ const validDateOptions = [
|
|||||||
{ value: "last_6_months", label: "Last 6 Months" },
|
{ value: "last_6_months", label: "Last 6 Months" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const spaceFilterOptions = [
|
type FilterStep = "main" | "validDate";
|
||||||
{ value: "active", label: "Active Facts" },
|
|
||||||
{ value: "archived", label: "Archived Facts" },
|
|
||||||
{ value: "all", label: "All Facts" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type FilterStep = "main" | "validDate" | "spaceFilter";
|
export function SpaceEpisodesFilters({
|
||||||
|
|
||||||
export function SpaceFactsFilters({
|
|
||||||
selectedValidDate,
|
selectedValidDate,
|
||||||
selectedSpaceFilter,
|
selectedSpaceFilter,
|
||||||
onValidDateChange,
|
onValidDateChange,
|
||||||
onSpaceFilterChange,
|
}: SpaceEpisodesFiltersProps) {
|
||||||
}: SpaceFactsFiltersProps) {
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const [step, setStep] = useState<FilterStep>("main");
|
const [step, setStep] = useState<FilterStep>("main");
|
||||||
|
|
||||||
const selectedValidDateLabel = validDateOptions.find(
|
const selectedValidDateLabel = validDateOptions.find(
|
||||||
(d) => d.value === selectedValidDate,
|
(d) => d.value === selectedValidDate,
|
||||||
)?.label;
|
)?.label;
|
||||||
const selectedSpaceFilterLabel = spaceFilterOptions.find(
|
|
||||||
(f) => f.value === selectedSpaceFilter,
|
|
||||||
)?.label;
|
|
||||||
|
|
||||||
const hasFilters = selectedValidDate || selectedSpaceFilter;
|
const hasFilters = selectedValidDate || selectedSpaceFilter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
|
<>
|
||||||
<Popover
|
<Popover
|
||||||
open={popoverOpen}
|
open={popoverOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -79,13 +69,6 @@ export function SpaceFactsFilters({
|
|||||||
>
|
>
|
||||||
Valid Date
|
Valid Date
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="justify-start"
|
|
||||||
onClick={() => setStep("spaceFilter")}
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -122,40 +105,6 @@ export function SpaceFactsFilters({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "spaceFilter" && (
|
|
||||||
<div className="flex flex-col gap-1 p-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => {
|
|
||||||
onSpaceFilterChange(undefined);
|
|
||||||
setPopoverOpen(false);
|
|
||||||
setStep("main");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All Facts
|
|
||||||
</Button>
|
|
||||||
{spaceFilterOptions.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => {
|
|
||||||
onSpaceFilterChange(
|
|
||||||
option.value === selectedSpaceFilter
|
|
||||||
? undefined
|
|
||||||
: option.value,
|
|
||||||
);
|
|
||||||
setPopoverOpen(false);
|
|
||||||
setStep("main");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</PopoverPortal>
|
</PopoverPortal>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -172,17 +121,8 @@ export function SpaceFactsFilters({
|
|||||||
/>
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{selectedSpaceFilter && (
|
|
||||||
<Badge variant="secondary" className="h-7 gap-1 rounded px-2">
|
|
||||||
{selectedSpaceFilterLabel}
|
|
||||||
<X
|
|
||||||
className="hover:text-destructive h-3.5 w-3.5 cursor-pointer"
|
|
||||||
onClick={() => onSpaceFilterChange(undefined)}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -9,25 +9,24 @@ import {
|
|||||||
} from "react-virtualized";
|
} from "react-virtualized";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import type { StatementNode } from "@core/types";
|
|
||||||
import { ScrollManagedList } from "../virtualized-list";
|
import { ScrollManagedList } from "../virtualized-list";
|
||||||
import { SpaceFactCard } from "./space-fact-card";
|
import { type Episode, SpaceEpisodeCard } from "./space-episode-card";
|
||||||
|
|
||||||
interface SpaceFactsListProps {
|
interface SpaceEpisodesListProps {
|
||||||
facts: any[];
|
episodes: any[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loadMore: () => void;
|
loadMore: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FactItemRenderer(
|
function EpisodeItemRenderer(
|
||||||
props: ListRowProps,
|
props: ListRowProps,
|
||||||
facts: StatementNode[],
|
episodes: Episode[],
|
||||||
cache: CellMeasurerCache,
|
cache: CellMeasurerCache,
|
||||||
) {
|
) {
|
||||||
const { index, key, style, parent } = props;
|
const { index, key, style, parent } = props;
|
||||||
const fact = facts[index];
|
const episode = episodes[index];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CellMeasurer
|
<CellMeasurer
|
||||||
@ -38,23 +37,23 @@ function FactItemRenderer(
|
|||||||
rowIndex={index}
|
rowIndex={index}
|
||||||
>
|
>
|
||||||
<div key={key} style={style} className="pb-2">
|
<div key={key} style={style} className="pb-2">
|
||||||
<SpaceFactCard fact={fact} />
|
<SpaceEpisodeCard episode={episode} />
|
||||||
</div>
|
</div>
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpaceFactsList({
|
export function SpaceEpisodesList({
|
||||||
facts,
|
episodes,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: SpaceFactsListProps) {
|
}: SpaceEpisodesListProps) {
|
||||||
// Create a CellMeasurerCache instance using useRef to prevent recreation
|
// Create a CellMeasurerCache instance using useRef to prevent recreation
|
||||||
const cacheRef = useRef<CellMeasurerCache | null>(null);
|
const cacheRef = useRef<CellMeasurerCache | null>(null);
|
||||||
if (!cacheRef.current) {
|
if (!cacheRef.current) {
|
||||||
cacheRef.current = new CellMeasurerCache({
|
cacheRef.current = new CellMeasurerCache({
|
||||||
defaultHeight: 200, // Default row height for fact cards
|
defaultHeight: 200, // Default row height for episode cards
|
||||||
fixedWidth: true, // Rows have fixed width but dynamic height
|
fixedWidth: true, // Rows have fixed width but dynamic height
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -62,17 +61,17 @@ export function SpaceFactsList({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cache.clearAll();
|
cache.clearAll();
|
||||||
}, [facts, cache]);
|
}, [episodes, cache]);
|
||||||
|
|
||||||
if (facts.length === 0 && !isLoading) {
|
if (episodes.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background-2 w-full">
|
<Card className="bg-background-2 w-full">
|
||||||
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
|
<CardContent className="bg-background-2 flex w-full items-center justify-center py-16">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||||
<h3 className="mb-2 text-lg font-semibold">No facts found</h3>
|
<h3 className="mb-2 text-lg font-semibold">No Episodes found</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
This space doesn't contain any facts yet.
|
This space doesn't contain any episodes yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -81,7 +80,7 @@ export function SpaceFactsList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRowLoaded = ({ index }: { index: number }) => {
|
const isRowLoaded = ({ index }: { index: number }) => {
|
||||||
return !!facts[index];
|
return !!episodes[index];
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreRows = async () => {
|
const loadMoreRows = async () => {
|
||||||
@ -92,14 +91,14 @@ export function SpaceFactsList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rowRenderer = (props: ListRowProps) => {
|
const rowRenderer = (props: ListRowProps) => {
|
||||||
return FactItemRenderer(props, facts, cache);
|
return EpisodeItemRenderer(props, episodes, cache);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowHeight = ({ index }: Index) => {
|
const rowHeight = ({ index }: Index) => {
|
||||||
return cache.getHeight(index, 0);
|
return cache.getHeight(index, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemCount = hasMore ? facts.length + 1 : facts.length;
|
const itemCount = hasMore ? episodes.length + 1 : episodes.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grow overflow-hidden rounded-lg">
|
<div className="h-full grow overflow-hidden rounded-lg">
|
||||||
@ -131,7 +130,7 @@ export function SpaceFactsList({
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="text-muted-foreground p-4 text-center text-sm">
|
<div className="text-muted-foreground p-4 text-center text-sm">
|
||||||
Loading more facts...
|
Loading more episodes...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { EllipsisVertical, RefreshCcw, Trash, Edit } from "lucide-react";
|
import { EllipsisVertical, RefreshCcw, Trash, Edit, Copy } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||||
import { EditSpaceDialog } from "./edit-space-dialog.client";
|
import { EditSpaceDialog } from "./edit-space-dialog.client";
|
||||||
|
import { toast } from "~/hooks/use-toast";
|
||||||
|
|
||||||
interface SpaceOptionsProps {
|
interface SpaceOptionsProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -64,6 +65,23 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
|
|||||||
// revalidator.revalidate();
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -79,6 +97,11 @@ export const SpaceOptions = ({ id, name, description }: SpaceOptionsProps) => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end">
|
<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)}>
|
<DropdownMenuItem onClick={() => setEditDialogOpen(true)}>
|
||||||
<Button variant="link" size="sm" className="gap-2 rounded">
|
<Button variant="link" size="sm" className="gap-2 rounded">
|
||||||
<Edit size={15} /> Edit
|
<Edit size={15} /> Edit
|
||||||
|
|||||||
@ -9,8 +9,8 @@ interface SpacesGridProps {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
autoMode: boolean;
|
autoMode: boolean;
|
||||||
statementCount: number | null;
|
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
contextCount?: number | null;
|
||||||
themes?: string[];
|
themes?: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ const CommandDialog = ({
|
|||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className={cn("overflow-hidden p-0 font-sans")}>
|
<DialogContent className={cn("overflow-hidden p-0 font-sans")}>
|
||||||
<Command
|
<Command
|
||||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-10 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
{...commandProps}
|
{...commandProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -141,7 +141,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"command-item aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded px-2 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -2,3 +2,5 @@ export * from "./button";
|
|||||||
export * from "./tabs";
|
export * from "./tabs";
|
||||||
export * from "./input";
|
export * from "./input";
|
||||||
export * from "./scrollarea";
|
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;
|
activityId?: string;
|
||||||
episodeUUID?: string;
|
episodeUUID?: string;
|
||||||
data?: any;
|
data?: any;
|
||||||
|
spaceIds?: string[];
|
||||||
|
episodeDetails?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogsResponse {
|
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";
|
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||||
|
|
||||||
export interface ExtendedUser extends User {
|
export interface ExtendedUser extends User {
|
||||||
availableCredits?: number;
|
availableCredits: number;
|
||||||
|
totalCredits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsImpersonating(matches?: UIMatch[]) {
|
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||||
@ -23,7 +24,11 @@ export function useOptionalUser(matches?: UIMatch[]): ExtendedUser | undefined {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return routeMatch?.user
|
return routeMatch?.user
|
||||||
? { ...routeMatch?.user, availableCredits: routeMatch?.availableCredits }
|
? {
|
||||||
|
...routeMatch?.user,
|
||||||
|
availableCredits: routeMatch?.availableCredits,
|
||||||
|
totalCredits: routeMatch?.totalCredits,
|
||||||
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,6 @@ export const addToQueue = async (
|
|||||||
|
|
||||||
const queuePersist = await prisma.ingestionQueue.create({
|
const queuePersist = await prisma.ingestionQueue.create({
|
||||||
data: {
|
data: {
|
||||||
spaceId: body.spaceId ? body.spaceId : null,
|
|
||||||
data: body,
|
data: body,
|
||||||
type: body.type,
|
type: body.type,
|
||||||
status: IngestionStatus.PENDING,
|
status: IngestionStatus.PENDING,
|
||||||
|
|||||||
@ -145,10 +145,8 @@ export const getClusteredGraphData = async (userId: string) => {
|
|||||||
rel.predicate as predicateLabel,
|
rel.predicate as predicateLabel,
|
||||||
e.uuid as episodeUuid,
|
e.uuid as episodeUuid,
|
||||||
e.content as episodeContent,
|
e.content as episodeContent,
|
||||||
|
e.spaceIds as spaceIds,
|
||||||
s.uuid as statementUuid,
|
s.uuid as statementUuid,
|
||||||
s.spaceIds as spaceIds,
|
|
||||||
s.fact as fact,
|
|
||||||
s.invalidAt as invalidAt,
|
|
||||||
s.validAt as validAt,
|
s.validAt as validAt,
|
||||||
s.createdAt as createdAt`,
|
s.createdAt as createdAt`,
|
||||||
{ userId },
|
{ userId },
|
||||||
@ -169,13 +167,8 @@ export const getClusteredGraphData = async (userId: string) => {
|
|||||||
|
|
||||||
const predicateLabel = record.get("predicateLabel");
|
const predicateLabel = record.get("predicateLabel");
|
||||||
const episodeUuid = record.get("episodeUuid");
|
const episodeUuid = record.get("episodeUuid");
|
||||||
const episodeContent = record.get("episodeContent");
|
|
||||||
const statementUuid = record.get("statementUuid");
|
|
||||||
const clusterIds = record.get("spaceIds");
|
const clusterIds = record.get("spaceIds");
|
||||||
const clusterId = clusterIds ? clusterIds[0] : undefined;
|
const clusterId = clusterIds ? clusterIds[0] : undefined;
|
||||||
const fact = record.get("fact");
|
|
||||||
const invalidAt = record.get("invalidAt");
|
|
||||||
const validAt = record.get("validAt");
|
|
||||||
const createdAt = record.get("createdAt");
|
const createdAt = record.get("createdAt");
|
||||||
|
|
||||||
// Create unique edge identifier to avoid duplicates
|
// Create unique edge identifier to avoid duplicates
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import type { Prisma, User } from "@core/database";
|
|||||||
import type { GoogleProfile } from "@coji/remix-auth-google";
|
import type { GoogleProfile } from "@coji/remix-auth-google";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
import { env } from "~/env.server";
|
import { env } from "~/env.server";
|
||||||
import { ensureBillingInitialized } from "~/services/billing.server";
|
|
||||||
export type { User } from "@core/database";
|
export type { User } from "@core/database";
|
||||||
|
|
||||||
type FindOrCreateMagicLink = {
|
type FindOrCreateMagicLink = {
|
||||||
@ -167,7 +166,12 @@ export async function findOrCreateGoogleUser({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserById(id: User["id"]) {
|
export async function getUserById(id: User["id"]) {
|
||||||
const user = await prisma.user.findUnique({ where: { id } });
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
Workspace: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -14,14 +14,26 @@ interface CreateWorkspaceDto {
|
|||||||
const spaceService = new SpaceService();
|
const spaceService = new SpaceService();
|
||||||
|
|
||||||
const profileRule = `
|
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):
|
Include (examples):
|
||||||
• Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain
|
• Preferred name, pronunciation, public handles (GitHub/Twitter/LinkedIn URLs), primary email domain
|
||||||
• Timezone, locale, working hours, meeting preferences (async/sync bias, default duration)
|
• Timezone, locale, working hours, meeting preferences (async/sync bias, default duration)
|
||||||
• Role, team, company, office location (city-level only), seniority
|
• Role, team, company, office location (city-level only), seniority
|
||||||
• Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS
|
• Tooling defaults (editor, ticketing system, repo host), keyboard layout, OS
|
||||||
• Communication preferences (tone, brevity vs. detail, summary-first)
|
• 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(
|
export async function createWorkspace(
|
||||||
input: CreateWorkspaceDto,
|
input: CreateWorkspaceDto,
|
||||||
@ -43,12 +55,33 @@ export async function createWorkspace(
|
|||||||
|
|
||||||
await ensureBillingInitialized(workspace.id);
|
await ensureBillingInitialized(workspace.id);
|
||||||
|
|
||||||
await spaceService.createSpace({
|
// Create default spaces
|
||||||
name: "Profile",
|
await Promise.all([
|
||||||
description: profileRule,
|
spaceService.createSpace({
|
||||||
userId: input.userId,
|
name: "Profile",
|
||||||
workspaceId: workspace.id,
|
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 {
|
try {
|
||||||
const response = await sendEmail({ email: "welcome", to: user.email });
|
const response = await sendEmail({ email: "welcome", to: user.email });
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
type ToastMessage,
|
type ToastMessage,
|
||||||
} from "./models/message.server";
|
} from "./models/message.server";
|
||||||
import { env } from "./env.server";
|
import { env } from "./env.server";
|
||||||
import { getUser, getUserRemainingCount } from "./services/session.server";
|
import { getUser } from "./services/session.server";
|
||||||
import { usePostHog } from "./hooks/usePostHog";
|
import { usePostHog } from "./hooks/usePostHog";
|
||||||
import {
|
import {
|
||||||
AppContainer,
|
AppContainer,
|
||||||
@ -40,6 +40,8 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from "remix-themes";
|
} from "remix-themes";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { getUsageSummary } from "./services/billing.server";
|
||||||
|
import { Toaster } from "./components/ui/toaster";
|
||||||
|
|
||||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
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 posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
||||||
const user = await getUser(request);
|
const user = await getUser(request);
|
||||||
const usage = await getUserRemainingCount(request);
|
const usageSummary = await getUsageSummary(user?.Workspace?.id as string);
|
||||||
|
|
||||||
return typedjson(
|
return typedjson(
|
||||||
{
|
{
|
||||||
user: user,
|
user: user,
|
||||||
availableCredits: usage?.availableCredits ?? 0,
|
availableCredits: usageSummary?.credits.available ?? 0,
|
||||||
|
totalCredits: usageSummary?.credits.monthly ?? 0,
|
||||||
toastMessage,
|
toastMessage,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
posthogProjectKey,
|
posthogProjectKey,
|
||||||
@ -124,6 +127,7 @@ function App() {
|
|||||||
</head>
|
</head>
|
||||||
<body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans">
|
<body className="bg-background-2 h-[100vh] h-full w-[100vw] overflow-hidden font-sans">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|
||||||
<Scripts />
|
<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",
|
corsStrategy: "all",
|
||||||
allowJWT: true,
|
allowJWT: true,
|
||||||
},
|
},
|
||||||
async ({ params }) => {
|
async ({ params, authentication }) => {
|
||||||
const formattedLog = await getIngestionQueueForFrontend(params.logId);
|
const formattedLog = await getIngestionQueueForFrontend(
|
||||||
|
params.logId,
|
||||||
|
authentication.userId,
|
||||||
|
);
|
||||||
|
|
||||||
return json({ log: formattedLog });
|
return json({ log: formattedLog });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||||
import { SpaceService } from "~/services/space.server";
|
import { SpaceService } from "~/services/space.server";
|
||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
|
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||||
|
|
||||||
const spaceService = new SpaceService();
|
const spaceService = new SpaceService();
|
||||||
|
|
||||||
@ -29,18 +30,20 @@ const { loader } = createActionApiRoute(
|
|||||||
return json({ error: "Space not found" }, { status: 404 });
|
return json({ error: "Space not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get statements in the space
|
// Get episodes in the space
|
||||||
const statements = await spaceService.getSpaceStatements(spaceId, userId);
|
const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
|
||||||
|
const episodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
statements,
|
episodes,
|
||||||
space: {
|
space: {
|
||||||
uuid: space.uuid,
|
uuid: space.uuid,
|
||||||
name: space.name,
|
name: space.name,
|
||||||
statementCount: statements.length
|
description: space.description,
|
||||||
|
episodeCount,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export { loader };
|
export { loader };
|
||||||
@ -1,16 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||||
createActionApiRoute,
|
|
||||||
createHybridActionApiRoute,
|
|
||||||
} from "~/services/routeBuilders/apiBuilder.server";
|
|
||||||
import { SpaceService } from "~/services/space.server";
|
import { SpaceService } from "~/services/space.server";
|
||||||
import { json } from "@remix-run/node";
|
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 { logger } from "~/services/logger.service";
|
||||||
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
||||||
|
|
||||||
@ -33,45 +24,26 @@ const { loader, action } = createHybridActionApiRoute(
|
|||||||
const { spaceId } = params;
|
const { spaceId } = params;
|
||||||
const spaceService = new SpaceService();
|
const spaceService = new SpaceService();
|
||||||
|
|
||||||
// Verify space exists and belongs to user
|
// Reset the space (clears all assignments, summary, and metadata)
|
||||||
const space = await prisma.space.findUnique({
|
const space = await spaceService.resetSpace(spaceId, userId);
|
||||||
where: {
|
|
||||||
id: spaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!space) {
|
|
||||||
return json({ error: "Space not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get statements in the space
|
logger.info(`Reset space ${space.id} successfully`);
|
||||||
await deleteSpace(spaceId, userId);
|
|
||||||
|
|
||||||
await createSpace(
|
// Trigger automatic episode assignment for the reset space
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
await triggerSpaceAssignment({
|
await triggerSpaceAssignment({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
workspaceId: space.workspaceId,
|
workspaceId: space.workspaceId,
|
||||||
mode: "new_space",
|
mode: "new_space",
|
||||||
newSpaceId: space.id,
|
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) {
|
} catch (error) {
|
||||||
// Don't fail space creation if LLM assignment fails
|
// Don't fail space reset if assignment fails
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to trigger LLM assignment for space ${space.id}:`,
|
`Failed to trigger assignment for space ${space.id}:`,
|
||||||
error as Record<string, unknown>,
|
error as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,19 +16,6 @@ const CreateSpaceSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schema for bulk operations
|
|
||||||
const BulkOperationSchema = z.object({
|
|
||||||
intent: z.enum([
|
|
||||||
"assign_statements",
|
|
||||||
"remove_statements",
|
|
||||||
"bulk_assign",
|
|
||||||
"initialize_space_ids",
|
|
||||||
]),
|
|
||||||
spaceId: z.string().optional(),
|
|
||||||
statementIds: z.array(z.string()).optional(),
|
|
||||||
spaceIds: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search query schema
|
// Search query schema
|
||||||
const SearchParamsSchema = z.object({
|
const SearchParamsSchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
@ -36,7 +23,7 @@ const SearchParamsSchema = z.object({
|
|||||||
|
|
||||||
const { action } = createHybridActionApiRoute(
|
const { action } = createHybridActionApiRoute(
|
||||||
{
|
{
|
||||||
body: z.union([CreateSpaceSchema, BulkOperationSchema]),
|
body: CreateSpaceSchema,
|
||||||
allowJWT: true,
|
allowJWT: true,
|
||||||
authorization: {
|
authorization: {
|
||||||
action: "manage",
|
action: "manage",
|
||||||
@ -82,96 +69,6 @@ const { action } = createHybridActionApiRoute(
|
|||||||
return json({ space, success: true });
|
return json({ space, success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "PUT") {
|
|
||||||
// Bulk operations
|
|
||||||
if (!body || !("intent" in body)) {
|
|
||||||
return json({ error: "Intent is required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (body.intent) {
|
|
||||||
case "assign_statements": {
|
|
||||||
if (!body.spaceId || !body.statementIds) {
|
|
||||||
return json(
|
|
||||||
{ error: "Space ID and statement IDs are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await spaceService.assignStatementsToSpace(
|
|
||||||
body.statementIds,
|
|
||||||
body.spaceId,
|
|
||||||
authentication.userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
message: `Assigned ${result.statementsUpdated} statements to space`,
|
|
||||||
statementsUpdated: result.statementsUpdated,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return json({ error: result.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "remove_statements": {
|
|
||||||
if (!body.spaceId || !body.statementIds) {
|
|
||||||
return json(
|
|
||||||
{ error: "Space ID and statement IDs are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await spaceService.removeStatementsFromSpace(
|
|
||||||
body.statementIds,
|
|
||||||
body.spaceId,
|
|
||||||
authentication.userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
message: `Removed ${result.statementsUpdated} statements from space`,
|
|
||||||
statementsUpdated: result.statementsUpdated,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return json({ error: result.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "bulk_assign": {
|
|
||||||
if (!body.statementIds || !body.spaceIds) {
|
|
||||||
return json(
|
|
||||||
{ error: "Statement IDs and space IDs are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await spaceService.bulkAssignStatements(
|
|
||||||
body.statementIds,
|
|
||||||
body.spaceIds,
|
|
||||||
authentication.userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return json({ results, success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
case "initialize_space_ids": {
|
|
||||||
const updatedCount = await spaceService.initializeSpaceIds(
|
|
||||||
authentication.userId,
|
|
||||||
);
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
message: `Initialized spaceIds for ${updatedCount} statements`,
|
|
||||||
updatedCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return json({ error: "Invalid intent" }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: "Method not allowed" }, { status: 405 });
|
return json({ error: "Method not allowed" }, { status: 405 });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,11 +9,11 @@ import { getIngestionQueueForFrontend } from "~/services/ingestionLogs.server";
|
|||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
const logId = params.logId;
|
const logId = params.logId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const log = await getIngestionQueueForFrontend(logId as string);
|
const log = await getIngestionQueueForFrontend(logId as string, userId);
|
||||||
return json({ log: log });
|
return json({ log: log });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json({ log: null });
|
return json({ log: null });
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
|||||||
import { useLoaderData } from "@remix-run/react";
|
import { useLoaderData } from "@remix-run/react";
|
||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
import { SpaceService } from "~/services/space.server";
|
import { SpaceService } from "~/services/space.server";
|
||||||
import { SpaceFactsFilters } from "~/components/spaces/space-facts-filters";
|
import { SpaceEpisodesFilters } from "~/components/spaces/space-episode-filters";
|
||||||
import { SpaceFactsList } from "~/components/spaces/space-facts-list";
|
import { SpaceEpisodesList } from "~/components/spaces/space-episodes-list";
|
||||||
|
|
||||||
import { ClientOnly } from "remix-utils/client-only";
|
import { ClientOnly } from "remix-utils/client-only";
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { getLogByEpisode } from "~/services/ingestionLogs.server";
|
||||||
|
import { Button } from "~/components/ui";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -15,16 +17,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
const spaceId = params.spaceId as string;
|
const spaceId = params.spaceId as string;
|
||||||
const space = await spaceService.getSpace(spaceId, userId);
|
const space = await spaceService.getSpace(spaceId, userId);
|
||||||
const statements = await spaceService.getSpaceStatements(spaceId, userId);
|
const episodes = await spaceService.getSpaceEpisodes(spaceId, userId);
|
||||||
|
|
||||||
|
const episodesWithLogData = await Promise.all(
|
||||||
|
episodes.map(async (ep) => {
|
||||||
|
const log = await getLogByEpisode(ep.uuid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ep,
|
||||||
|
logId: log?.id,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
space,
|
space,
|
||||||
statements: statements || [],
|
episodes: episodesWithLogData || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Facts() {
|
export default function Episodes() {
|
||||||
const { statements } = useLoaderData<typeof loader>();
|
const { episodes } = useLoaderData<typeof loader>();
|
||||||
const [selectedValidDate, setSelectedValidDate] = useState<
|
const [selectedValidDate, setSelectedValidDate] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
@ -32,42 +45,27 @@ export default function Facts() {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// Filter statements based on selected filters
|
// Filter episodes based on selected filters
|
||||||
const filteredStatements = statements.filter((statement) => {
|
const filteredEpisodes = episodes.filter((episode) => {
|
||||||
// Date filter
|
// Date filter
|
||||||
if (selectedValidDate) {
|
if (selectedValidDate) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const statementDate = new Date(statement.validAt);
|
const episodeDate = new Date(episode.createdAt);
|
||||||
|
|
||||||
switch (selectedValidDate) {
|
switch (selectedValidDate) {
|
||||||
case "last_week":
|
case "last_week":
|
||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
if (statementDate < weekAgo) return false;
|
if (episodeDate < weekAgo) return false;
|
||||||
break;
|
break;
|
||||||
case "last_month":
|
case "last_month":
|
||||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
if (statementDate < monthAgo) return false;
|
if (episodeDate < monthAgo) return false;
|
||||||
break;
|
break;
|
||||||
case "last_6_months":
|
case "last_6_months":
|
||||||
const sixMonthsAgo = new Date(
|
const sixMonthsAgo = new Date(
|
||||||
now.getTime() - 180 * 24 * 60 * 60 * 1000,
|
now.getTime() - 180 * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
if (statementDate < sixMonthsAgo) return false;
|
if (episodeDate < sixMonthsAgo) return false;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
if (selectedSpaceFilter) {
|
|
||||||
switch (selectedSpaceFilter) {
|
|
||||||
case "active":
|
|
||||||
if (statement.invalidAt) return false;
|
|
||||||
break;
|
|
||||||
case "archived":
|
|
||||||
if (!statement.invalidAt) return false;
|
|
||||||
break;
|
|
||||||
case "all":
|
|
||||||
default:
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,20 +79,22 @@ export default function Facts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col pt-5">
|
<div className="flex h-full w-full flex-col pt-5">
|
||||||
<SpaceFactsFilters
|
<div className="mb-2 flex w-full items-center justify-start gap-2 px-5">
|
||||||
selectedValidDate={selectedValidDate}
|
<SpaceEpisodesFilters
|
||||||
selectedSpaceFilter={selectedSpaceFilter}
|
selectedValidDate={selectedValidDate}
|
||||||
onValidDateChange={setSelectedValidDate}
|
selectedSpaceFilter={selectedSpaceFilter}
|
||||||
onSpaceFilterChange={setSelectedSpaceFilter}
|
onValidDateChange={setSelectedValidDate}
|
||||||
/>
|
onSpaceFilterChange={setSelectedSpaceFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex h-[calc(100vh_-_56px)] w-full">
|
<div className="flex h-[calc(100vh_-_56px)] w-full">
|
||||||
<ClientOnly
|
<ClientOnly
|
||||||
fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
fallback={<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<SpaceFactsList
|
<SpaceEpisodesList
|
||||||
facts={filteredStatements}
|
episodes={filteredEpisodes}
|
||||||
hasMore={false} // TODO: Implement real pagination
|
hasMore={false} // TODO: Implement real pagination
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
@ -148,7 +148,7 @@ export default function Overview() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground mb-1 -ml-2 gap-1"
|
className="text-muted-foreground mb-1 -ml-2 gap-1"
|
||||||
>
|
>
|
||||||
Summary
|
Context
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={`transition-transform duration-300 ${
|
className={`transition-transform duration-300 ${
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import { useTypedLoaderData } from "remix-typedjson";
|
|||||||
import { Outlet, useLocation, useNavigate } from "@remix-run/react";
|
import { Outlet, useLocation, useNavigate } from "@remix-run/react";
|
||||||
import { SpaceOptions } from "~/components/spaces/space-options";
|
import { SpaceOptions } from "~/components/spaces/space-options";
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui";
|
||||||
|
import React from "react";
|
||||||
|
import { AddMemoryDialog } from "~/components/command-bar/memory-dialog.client";
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
@ -23,6 +26,7 @@ export default function Space() {
|
|||||||
const space = useTypedLoaderData<typeof loader>();
|
const space = useTypedLoaderData<typeof loader>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [showAddMemory, setShowAddMemory] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -46,16 +50,10 @@ export default function Space() {
|
|||||||
onClick: () => navigate(`/home/space/${space.id}/overview`),
|
onClick: () => navigate(`/home/space/${space.id}/overview`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Facts",
|
label: "Episodes",
|
||||||
value: "facts",
|
value: "edpisodes",
|
||||||
isActive: location.pathname.includes("/facts"),
|
isActive: location.pathname.includes("/episodes"),
|
||||||
onClick: () => navigate(`/home/space/${space.id}/facts`),
|
onClick: () => navigate(`/home/space/${space.id}/episodes`),
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Patterns",
|
|
||||||
value: "patterns",
|
|
||||||
isActive: location.pathname.includes("/patterns"),
|
|
||||||
onClick: () => navigate(`/home/space/${space.id}/patterns`),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
actionsNode={
|
actionsNode={
|
||||||
@ -67,17 +65,33 @@ export default function Space() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<SpaceOptions
|
<div className="flex items-center gap-2">
|
||||||
id={space.id as string}
|
<Button
|
||||||
name={space.name}
|
variant="secondary"
|
||||||
description={space.description}
|
onClick={() => setShowAddMemory(true)}
|
||||||
/>
|
>
|
||||||
|
Add episode
|
||||||
|
</Button>
|
||||||
|
<SpaceOptions
|
||||||
|
id={space.id as string}
|
||||||
|
name={space.name}
|
||||||
|
description={space.description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
|
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-start overflow-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
|
{showAddMemory && (
|
||||||
|
<AddMemoryDialog
|
||||||
|
open={showAddMemory}
|
||||||
|
onOpenChange={setShowAddMemory}
|
||||||
|
defaultSpaceId={space.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -153,6 +153,7 @@ export default function Onboarding() {
|
|||||||
setCurrentQuestion(currentQuestion + 1);
|
setCurrentQuestion(currentQuestion + 1);
|
||||||
} else {
|
} else {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Submit all answers
|
// Submit all answers
|
||||||
submitAnswers();
|
submitAnswers();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -262,6 +262,7 @@ export default function BillingSettings() {
|
|||||||
<Progress
|
<Progress
|
||||||
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
|
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
|
color="#c15e50"
|
||||||
/>
|
/>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{usageSummary.credits.percentageUsed}% used this period
|
{usageSummary.credits.percentageUsed}% used this period
|
||||||
@ -452,7 +453,7 @@ export default function BillingSettings() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-6 p-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{/* Free Plan */}
|
{/* Free Plan */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@ -467,10 +468,10 @@ export default function BillingSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="mb-6 space-y-2 text-sm">
|
<ul className="mb-6 space-y-2 text-sm">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>Memory facts: 3k/mo</span>
|
<span>Credits: 3k/mo</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>NO USAGE BASED</span>
|
<span>No usage based</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
@ -504,14 +505,15 @@ export default function BillingSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="mb-6 space-y-2 text-sm">
|
<ul className="mb-6 space-y-2 text-sm">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>Memory facts: 15k/mo</span>
|
<span>Credits: 15k/mo</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>$0.299 /1K ADDITIONAL FACTS</span>
|
<span>$0.299 /1K Additional Credits</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
disabled={
|
disabled={
|
||||||
usageSummary.plan.type === "PRO" ||
|
usageSummary.plan.type === "PRO" ||
|
||||||
fetcher.state === "submitting"
|
fetcher.state === "submitting"
|
||||||
@ -540,14 +542,15 @@ export default function BillingSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<ul className="mb-6 space-y-2 text-sm">
|
<ul className="mb-6 space-y-2 text-sm">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>Memory facts: 100k/mo</span>
|
<span>Credits: 100k/mo</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>$0.249 /1K ADDITIONAL FACTS</span>
|
<span>$0.249 /1K Additional Credits</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
disabled={
|
disabled={
|
||||||
usageSummary.plan.type === "MAX" ||
|
usageSummary.plan.type === "MAX" ||
|
||||||
fetcher.state === "submitting"
|
fetcher.state === "submitting"
|
||||||
|
|||||||
@ -160,6 +160,10 @@ export async function ensureBillingInitialized(workspaceId: string) {
|
|||||||
* Get workspace usage summary
|
* Get workspace usage summary
|
||||||
*/
|
*/
|
||||||
export async function getUsageSummary(workspaceId: string) {
|
export async function getUsageSummary(workspaceId: string) {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure billing records exist for existing accounts
|
// Ensure billing records exist for existing accounts
|
||||||
await ensureBillingInitialized(workspaceId);
|
await ensureBillingInitialized(workspaceId);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { runQuery } from "~/lib/neo4j.server";
|
import { runQuery } from "~/lib/neo4j.server";
|
||||||
import { type StatementNode, type EntityNode, type EpisodicNode } from "@core/types";
|
import {
|
||||||
|
type StatementNode,
|
||||||
|
type EntityNode,
|
||||||
|
type EpisodicNode,
|
||||||
|
} from "@core/types";
|
||||||
|
|
||||||
export async function saveEpisode(episode: EpisodicNode): Promise<string> {
|
export async function saveEpisode(episode: EpisodicNode): Promise<string> {
|
||||||
const query = `
|
const query = `
|
||||||
@ -72,6 +76,8 @@ export async function getEpisode(uuid: string): Promise<EpisodicNode | null> {
|
|||||||
userId: episode.userId,
|
userId: episode.userId,
|
||||||
space: episode.space,
|
space: episode.space,
|
||||||
sessionId: episode.sessionId,
|
sessionId: episode.sessionId,
|
||||||
|
recallCount: episode.recallCount,
|
||||||
|
spaceIds: episode.spaceIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +146,7 @@ export async function searchEpisodesByEmbedding(params: {
|
|||||||
}) {
|
}) {
|
||||||
const limit = params.limit || 100;
|
const limit = params.limit || 100;
|
||||||
const query = `
|
const query = `
|
||||||
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding)
|
CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
|
||||||
YIELD node AS episode
|
YIELD node AS episode
|
||||||
WHERE episode.userId = $userId
|
WHERE episode.userId = $userId
|
||||||
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
|
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
|
||||||
@ -285,7 +291,7 @@ export async function getRelatedEpisodesEntities(params: {
|
|||||||
}) {
|
}) {
|
||||||
const limit = params.limit || 100;
|
const limit = params.limit || 100;
|
||||||
const query = `
|
const query = `
|
||||||
CALL db.index.vector.queryNodes('episode_embedding', ${limit*2}, $embedding)
|
CALL db.index.vector.queryNodes('episode_embedding', ${limit * 2}, $embedding)
|
||||||
YIELD node AS episode
|
YIELD node AS episode
|
||||||
WHERE episode.userId = $userId
|
WHERE episode.userId = $userId
|
||||||
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
|
WITH episode, gds.similarity.cosine(episode.contentEmbedding, $embedding) AS score
|
||||||
|
|||||||
@ -56,13 +56,12 @@ export async function getSpace(
|
|||||||
const query = `
|
const query = `
|
||||||
MATCH (s:Space {uuid: $spaceId, userId: $userId})
|
MATCH (s:Space {uuid: $spaceId, userId: $userId})
|
||||||
WHERE s.isActive = true
|
WHERE s.isActive = true
|
||||||
|
|
||||||
// Count statements in this space using optimized approach
|
// Count episodes assigned to this space using direct relationship
|
||||||
OPTIONAL MATCH (stmt:Statement {userId: $userId})
|
OPTIONAL MATCH (s)-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||||
WHERE stmt.spaceIds IS NOT NULL AND $spaceId IN stmt.spaceIds AND stmt.invalidAt IS NULL
|
|
||||||
|
WITH s, count(e) as episodeCount
|
||||||
WITH s, count(stmt) as statementCount
|
RETURN s, episodeCount
|
||||||
RETURN s, statementCount
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await runQuery(query, { spaceId, userId });
|
const result = await runQuery(query, { spaceId, userId });
|
||||||
@ -71,7 +70,7 @@ export async function getSpace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const spaceData = result[0].get("s").properties;
|
const spaceData = result[0].get("s").properties;
|
||||||
const statementCount = result[0].get("statementCount") || 0;
|
const episodeCount = result[0].get("episodeCount") || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uuid: spaceData.uuid,
|
uuid: spaceData.uuid,
|
||||||
@ -81,7 +80,7 @@ export async function getSpace(
|
|||||||
createdAt: new Date(spaceData.createdAt),
|
createdAt: new Date(spaceData.createdAt),
|
||||||
updatedAt: new Date(spaceData.updatedAt),
|
updatedAt: new Date(spaceData.updatedAt),
|
||||||
isActive: spaceData.isActive,
|
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)
|
// 2. Clean up statement references (remove spaceId from spaceIds arrays)
|
||||||
const cleanupQuery = `
|
const cleanupStatementsQuery = `
|
||||||
MATCH (s:Statement {userId: $userId})
|
MATCH (s:Statement {userId: $userId})
|
||||||
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
|
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
|
||||||
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
|
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
|
||||||
RETURN count(s) as updatedStatements
|
RETURN count(s) as updatedStatements
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const cleanupResult = await runQuery(cleanupQuery, { userId, spaceId });
|
const cleanupStatementsResult = await runQuery(cleanupStatementsQuery, {
|
||||||
const updatedStatements = cleanupResult[0]?.get("updatedStatements") || 0;
|
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 = `
|
const deleteQuery = `
|
||||||
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||||
DELETE space
|
DETACH DELETE space
|
||||||
RETURN count(space) as deletedSpaces
|
RETURN count(space) as deletedSpaces
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await runQuery(deleteQuery, { userId, spaceId });
|
await runQuery(deleteQuery, { userId, spaceId });
|
||||||
|
|
||||||
|
logger.info(`Deleted space ${spaceId}`, {
|
||||||
|
userId,
|
||||||
|
statementsUpdated: updatedStatements,
|
||||||
|
episodesUpdated: updatedEpisodes,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleted: true,
|
deleted: true,
|
||||||
statementsUpdated: Number(updatedStatements),
|
statementsUpdated: Number(updatedStatements) + Number(updatedEpisodes),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
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(
|
export async function assignEpisodesToSpace(
|
||||||
statementIds: string[],
|
episodeIds: string[],
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SpaceAssignmentResult> {
|
): 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 = `
|
const query = `
|
||||||
MATCH (s:Statement {userId: $userId})
|
MATCH (space:Space {uuid: $spaceId, userId: $userId})
|
||||||
WHERE s.uuid IN $statementIds
|
MATCH (e:Episode {userId: $userId})
|
||||||
SET s.spaceIds = CASE
|
WHERE e.uuid IN $episodeIds
|
||||||
WHEN s.spaceIds IS NULL THEN [$spaceId]
|
SET e.spaceIds = CASE
|
||||||
WHEN $spaceId IN s.spaceIds THEN s.spaceIds
|
WHEN e.spaceIds IS NULL THEN [$spaceId]
|
||||||
ELSE s.spaceIds + [$spaceId]
|
WHEN $spaceId IN e.spaceIds THEN e.spaceIds
|
||||||
|
ELSE e.spaceIds + [$spaceId]
|
||||||
END,
|
END,
|
||||||
s.lastSpaceAssignment = datetime(),
|
e.lastSpaceAssignment = datetime(),
|
||||||
s.spaceAssignmentMethod = CASE
|
e.spaceAssignmentMethod = CASE
|
||||||
WHEN s.spaceAssignmentMethod IS NULL THEN 'manual'
|
WHEN e.spaceAssignmentMethod IS NULL THEN 'intent_based'
|
||||||
ELSE s.spaceAssignmentMethod
|
ELSE e.spaceAssignmentMethod
|
||||||
END
|
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;
|
const updatedCount = result[0]?.get("updated") || 0;
|
||||||
|
|
||||||
|
logger.info(`Assigned ${updatedCount} episodes to space ${spaceId}`, {
|
||||||
|
episodeIds: episodeIds.length,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
statementsUpdated: Number(updatedCount),
|
statementsUpdated: Number(updatedCount),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error(`Error assigning episodes to space:`, {
|
||||||
|
error,
|
||||||
|
spaceId,
|
||||||
|
episodeIds: episodeIds.length,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
statementsUpdated: 0,
|
statementsUpdated: 0,
|
||||||
@ -235,22 +277,26 @@ export async function assignStatementsToSpace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove statements from a space
|
* Remove episodes from a space
|
||||||
*/
|
*/
|
||||||
export async function removeStatementsFromSpace(
|
export async function removeEpisodesFromSpace(
|
||||||
statementIds: string[],
|
episodeIds: string[],
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SpaceAssignmentResult> {
|
): Promise<SpaceAssignmentResult> {
|
||||||
try {
|
try {
|
||||||
|
// Remove from both spaceIds array and HAS_EPISODE relationship
|
||||||
const query = `
|
const query = `
|
||||||
MATCH (s:Statement {userId: $userId})
|
MATCH (e:Episode {userId: $userId})
|
||||||
WHERE s.uuid IN $statementIds AND s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds
|
WHERE e.uuid IN $episodeIds AND e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||||
SET s.spaceIds = [id IN s.spaceIds WHERE id <> $spaceId]
|
SET e.spaceIds = [id IN e.spaceIds WHERE id <> $spaceId]
|
||||||
RETURN count(s) as updated
|
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;
|
const updatedCount = result[0]?.get("updated") || 0;
|
||||||
|
|
||||||
return {
|
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 = `
|
const query = `
|
||||||
MATCH (s:Statement {userId: $userId})
|
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||||
WHERE s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL
|
RETURN e
|
||||||
MATCH (s)-[:HAS_SUBJECT]->(subj:Entity)
|
ORDER BY e.createdAt DESC
|
||||||
MATCH (s)-[:HAS_PREDICATE]->(pred:Entity)
|
|
||||||
MATCH (s)-[:HAS_OBJECT]->(obj:Entity)
|
|
||||||
RETURN s, subj.name as subject, pred.name as predicate, obj.name as object
|
|
||||||
ORDER BY s.createdAt DESC
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await runQuery(query, { spaceId, userId });
|
const result = await runQuery(query, { spaceId, userId });
|
||||||
|
|
||||||
return result.map((record) => {
|
return result.map((record) => {
|
||||||
const statement = record.get("s").properties;
|
const episode = record.get("e").properties;
|
||||||
return {
|
return {
|
||||||
uuid: statement.uuid,
|
uuid: episode.uuid,
|
||||||
fact: statement.fact,
|
content: episode.content,
|
||||||
subject: record.get("subject"),
|
originalContent: episode.originalContent,
|
||||||
predicate: record.get("predicate"),
|
source: episode.source,
|
||||||
object: record.get("object"),
|
createdAt: new Date(episode.createdAt),
|
||||||
createdAt: new Date(statement.createdAt),
|
validAt: new Date(episode.validAt),
|
||||||
validAt: new Date(statement.validAt),
|
metadata: JSON.parse(episode.metadata || "{}"),
|
||||||
invalidAt: statement.invalidAt
|
sessionId: episode.sessionId,
|
||||||
? new Date(statement.invalidAt)
|
|
||||||
: undefined,
|
|
||||||
spaceIds: statement.spaceIds || [],
|
|
||||||
recallCount: statement.recallCount,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
// Use spaceIds array for faster lookup instead of relationship traversal
|
||||||
const query = `
|
const query = `
|
||||||
MATCH (s:Statement {userId: $userId})
|
MATCH (e:Episode {userId: $userId})
|
||||||
WHERE s.spaceIds IS NOT NULL
|
WHERE e.spaceIds IS NOT NULL AND $spaceId IN e.spaceIds
|
||||||
AND $spaceId IN s.spaceIds
|
RETURN count(e) as episodeCount
|
||||||
RETURN count(s) as statementCount
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await runQuery(query, { spaceId, userId });
|
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(
|
export async function getSpacesForEpisodes(
|
||||||
spaceId: string,
|
episodeIds: string[],
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{
|
): Promise<Record<string, string[]>> {
|
||||||
shouldTrigger: boolean;
|
const query = `
|
||||||
isNewSpace: boolean;
|
UNWIND $episodeIds as episodeId
|
||||||
currentCount: number;
|
MATCH (e:Episode {uuid: episodeId, userId: $userId})
|
||||||
}> {
|
WHERE e.spaceIds IS NOT NULL AND size(e.spaceIds) > 0
|
||||||
try {
|
RETURN episodeId, e.spaceIds as spaceIds
|
||||||
// Get current statement count from Neo4j
|
`;
|
||||||
const currentCount = await getSpaceStatementCount(spaceId, userId);
|
|
||||||
|
|
||||||
// Get space data from PostgreSQL
|
const result = await runQuery(query, { episodeIds, userId });
|
||||||
const space = await prisma.space.findUnique({
|
|
||||||
where: { id: spaceId },
|
|
||||||
select: {
|
|
||||||
lastPatternTrigger: true,
|
|
||||||
statementCountAtLastTrigger: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!space) {
|
const spacesMap: Record<string, string[]> = {};
|
||||||
logger.warn(`Space ${spaceId} not found when checking pattern trigger`);
|
|
||||||
return { shouldTrigger: false, isNewSpace: false, currentCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNewSpace = !space.lastPatternTrigger;
|
// Initialize all episodes with empty arrays
|
||||||
const previousCount = space.statementCountAtLastTrigger || 0;
|
episodeIds.forEach((id) => {
|
||||||
const growth = currentCount - previousCount;
|
spacesMap[id] = [];
|
||||||
|
});
|
||||||
|
|
||||||
// Trigger if: new space OR growth >= 100 statements
|
// Fill in the spaceIds for episodes that have them
|
||||||
const shouldTrigger = isNewSpace || growth >= 100;
|
result.forEach((record) => {
|
||||||
|
const episodeId = record.get("episodeId");
|
||||||
|
const spaceIds = record.get("spaceIds");
|
||||||
|
spacesMap[episodeId] = spaceIds || [];
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`Space pattern trigger check`, {
|
return spacesMap;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
|
import { getEpisode } from "./graphModels/episode";
|
||||||
|
import { getSpacesForEpisodes } from "./graphModels/space";
|
||||||
|
|
||||||
export async function getIngestionLogs(
|
export async function getIngestionLogs(
|
||||||
userId: string,
|
userId: string,
|
||||||
@ -53,7 +55,10 @@ export const getIngestionQueue = async (id: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getIngestionQueueForFrontend = async (id: string) => {
|
export const getIngestionQueueForFrontend = async (
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
// Fetch the specific log by logId
|
// Fetch the specific log by logId
|
||||||
const log = await prisma.ingestionQueue.findUnique({
|
const log = await prisma.ingestionQueue.findUnique({
|
||||||
where: { id: id },
|
where: { id: id },
|
||||||
@ -66,6 +71,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
|
|||||||
type: true,
|
type: true,
|
||||||
output: true,
|
output: true,
|
||||||
data: true,
|
data: true,
|
||||||
|
workspaceId: true,
|
||||||
activity: {
|
activity: {
|
||||||
select: {
|
select: {
|
||||||
text: true,
|
text: true,
|
||||||
@ -94,7 +100,7 @@ export const getIngestionQueueForFrontend = async (id: string) => {
|
|||||||
log.activity?.integrationAccount?.integrationDefinition;
|
log.activity?.integrationAccount?.integrationDefinition;
|
||||||
const logData = log.data as any;
|
const logData = log.data as any;
|
||||||
|
|
||||||
const formattedLog = {
|
const formattedLog: any = {
|
||||||
id: log.id,
|
id: log.id,
|
||||||
source: integrationDef?.name || logData?.source || "Unknown",
|
source: integrationDef?.name || logData?.source || "Unknown",
|
||||||
ingestText:
|
ingestText:
|
||||||
@ -112,9 +118,76 @@ export const getIngestionQueueForFrontend = async (id: string) => {
|
|||||||
data: log.data,
|
data: log.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch space data based on log type
|
||||||
|
if (logData?.type === "CONVERSATION" && formattedLog?.episodeUUID) {
|
||||||
|
// For CONVERSATION type: get spaceIds for the single episode
|
||||||
|
const spacesMap = await getSpacesForEpisodes(
|
||||||
|
[formattedLog.episodeUUID],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
formattedLog.spaceIds = spacesMap[formattedLog.episodeUUID] || [];
|
||||||
|
} else if (
|
||||||
|
logData?.type === "DOCUMENT" &&
|
||||||
|
(log.output as any)?.episodes?.length > 0
|
||||||
|
) {
|
||||||
|
// For DOCUMENT type: get episode details and space information for all episodes
|
||||||
|
const episodeIds = (log.output as any)?.episodes;
|
||||||
|
|
||||||
|
// Fetch all episode details in parallel
|
||||||
|
const episodeDetailsPromises = episodeIds.map((episodeId: string) =>
|
||||||
|
getEpisode(episodeId).catch(() => null),
|
||||||
|
);
|
||||||
|
const episodeDetails = await Promise.all(episodeDetailsPromises);
|
||||||
|
|
||||||
|
// Get spaceIds for all episodes
|
||||||
|
const spacesMap = await getSpacesForEpisodes(episodeIds, userId);
|
||||||
|
|
||||||
|
// Combine episode details with space information
|
||||||
|
formattedLog.episodeDetails = episodeIds.map(
|
||||||
|
(episodeId: string, index: number) => {
|
||||||
|
const episode = episodeDetails[index];
|
||||||
|
return {
|
||||||
|
uuid: episodeId,
|
||||||
|
content: episode?.content || episode?.originalContent || "No content",
|
||||||
|
spaceIds: spacesMap[episodeId] || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return formattedLog;
|
return formattedLog;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getLogByEpisode = async (episodeUuid: string) => {
|
||||||
|
// Find logs where the episode UUID matches either:
|
||||||
|
// 1. log.output.episodeUuid (single episode - CONVERSATION type)
|
||||||
|
// 2. log.output.episodes array (multiple episodes - DOCUMENT type)
|
||||||
|
const logs = await prisma.ingestionQueue.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
output: {
|
||||||
|
path: ["episodeUuid"],
|
||||||
|
equals: episodeUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
output: {
|
||||||
|
path: ["episodes"],
|
||||||
|
array_contains: episodeUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return logs[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteIngestionQueue = async (id: string) => {
|
export const deleteIngestionQueue = async (id: string) => {
|
||||||
return await prisma.ingestionQueue.delete({
|
return await prisma.ingestionQueue.delete({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@ -19,7 +19,8 @@ import { ensureBillingInitialized } from "./billing.server";
|
|||||||
const QueryParams = z.object({
|
const QueryParams = z.object({
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
integrations: z.string().optional(), // comma-separated slugs
|
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
|
// Create MCP server with memory tools + dynamic integration tools
|
||||||
@ -27,6 +28,7 @@ async function createMcpServer(
|
|||||||
userId: string,
|
userId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
source: string,
|
source: string,
|
||||||
|
spaceId?: string,
|
||||||
) {
|
) {
|
||||||
const server = new Server(
|
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 () => {
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
// Get integration tools
|
// Only return memory tools (which now includes integration meta-tools)
|
||||||
let integrationTools: any[] = [];
|
// Integration-specific tools are discovered via get_integration_actions
|
||||||
try {
|
|
||||||
integrationTools =
|
|
||||||
await IntegrationLoader.getAllIntegrationTools(sessionId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error loading integration tools: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tools: [...memoryTools, ...integrationTools],
|
tools: memoryTools,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,30 +55,21 @@ async function createMcpServer(
|
|||||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
// Handle memory tools
|
// Handle memory tools and integration meta-tools
|
||||||
if (name.startsWith("memory_")) {
|
if (
|
||||||
return await callMemoryTool(name, args, userId, source);
|
name.startsWith("memory_") ||
|
||||||
}
|
name === "get_integrations" ||
|
||||||
|
name === "get_integration_actions" ||
|
||||||
// Handle integration tools (prefixed with integration slug)
|
name === "execute_integration_action"
|
||||||
if (name.includes("_") && !name.startsWith("memory_")) {
|
) {
|
||||||
try {
|
// Get workspace for integration tools
|
||||||
return await IntegrationLoader.callIntegrationTool(
|
const workspace = await getWorkspaceByUser(userId);
|
||||||
sessionId,
|
return await callMemoryTool(
|
||||||
name,
|
name,
|
||||||
args,
|
{ ...args, sessionId, workspaceId: workspace?.id, spaceId },
|
||||||
);
|
userId,
|
||||||
} catch (error) {
|
source,
|
||||||
return {
|
);
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error calling integration tool: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
@ -100,6 +86,7 @@ async function createTransport(
|
|||||||
noIntegrations: boolean,
|
noIntegrations: boolean,
|
||||||
userId: string,
|
userId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
spaceId?: string,
|
||||||
): Promise<StreamableHTTPServerTransport> {
|
): Promise<StreamableHTTPServerTransport> {
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => sessionId,
|
sessionIdGenerator: () => sessionId,
|
||||||
@ -171,7 +158,7 @@ async function createTransport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and connect MCP server
|
// Create and connect MCP server
|
||||||
const server = await createMcpServer(userId, sessionId, source);
|
const server = await createMcpServer(userId, sessionId, source, spaceId);
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|
||||||
return transport;
|
return transport;
|
||||||
@ -191,6 +178,7 @@ export const handleMCPRequest = async (
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const noIntegrations = queryParams.no_integrations ?? false;
|
const noIntegrations = queryParams.no_integrations ?? false;
|
||||||
|
const spaceId = queryParams.spaceId; // Extract spaceId from query params
|
||||||
|
|
||||||
const userId = authentication.userId;
|
const userId = authentication.userId;
|
||||||
const workspace = await getWorkspaceByUser(userId);
|
const workspace = await getWorkspaceByUser(userId);
|
||||||
@ -220,6 +208,7 @@ export const handleMCPRequest = async (
|
|||||||
noIntegrations,
|
noIntegrations,
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Session not found in database");
|
throw new Error("Session not found in database");
|
||||||
@ -237,6 +226,7 @@ export const handleMCPRequest = async (
|
|||||||
noIntegrations,
|
noIntegrations,
|
||||||
userId,
|
userId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Invalid request
|
// Invalid request
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export class SearchService {
|
|||||||
options: SearchOptions = {},
|
options: SearchOptions = {},
|
||||||
source?: string,
|
source?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
episodes: string[];
|
episodes: {content: string; createdAt: Date; spaceIds: string[]}[];
|
||||||
facts: {
|
facts: {
|
||||||
fact: string;
|
fact: string;
|
||||||
validAt: Date;
|
validAt: Date;
|
||||||
@ -108,7 +108,11 @@ export class SearchService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
episodes: episodes.map((episode) => episode.originalContent),
|
episodes: episodes.map((episode) => ({
|
||||||
|
content: episode.originalContent,
|
||||||
|
createdAt: episode.createdAt,
|
||||||
|
spaceIds: episode.spaceIds || [],
|
||||||
|
})),
|
||||||
facts: filteredResults.map((statement) => ({
|
facts: filteredResults.map((statement) => ({
|
||||||
fact: statement.statement.fact,
|
fact: statement.statement.fact,
|
||||||
validAt: statement.statement.validAt,
|
validAt: statement.statement.validAt,
|
||||||
|
|||||||
@ -33,14 +33,6 @@ export async function getUser(request: Request) {
|
|||||||
throw await logout(request);
|
throw await logout(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserRemainingCount(request: Request) {
|
|
||||||
const userId = await getUserId(request);
|
|
||||||
if (userId === undefined) return null;
|
|
||||||
|
|
||||||
const userUsage = await getUserLeftCredits(userId);
|
|
||||||
if (userUsage) return userUsage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireUserId(request: Request, redirectTo?: string) {
|
export async function requireUserId(request: Request, redirectTo?: string) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@ -71,6 +63,7 @@ export async function requireUser(request: Request) {
|
|||||||
confirmedBasicDetails: user.confirmedBasicDetails,
|
confirmedBasicDetails: user.confirmedBasicDetails,
|
||||||
onboardingComplete: user.onboardingComplete,
|
onboardingComplete: user.onboardingComplete,
|
||||||
isImpersonating: !!impersonationId,
|
isImpersonating: !!impersonationId,
|
||||||
|
workspaceId: user.Workspace?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,19 +3,18 @@ import {
|
|||||||
type SpaceNode,
|
type SpaceNode,
|
||||||
type CreateSpaceParams,
|
type CreateSpaceParams,
|
||||||
type UpdateSpaceParams,
|
type UpdateSpaceParams,
|
||||||
type SpaceAssignmentResult,
|
|
||||||
} from "@core/types";
|
} from "@core/types";
|
||||||
import { type Space } from "@prisma/client";
|
import { type Space } from "@prisma/client";
|
||||||
|
|
||||||
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
import { triggerSpaceAssignment } from "~/trigger/spaces/space-assignment";
|
||||||
import {
|
import {
|
||||||
assignStatementsToSpace,
|
assignEpisodesToSpace,
|
||||||
createSpace,
|
createSpace,
|
||||||
deleteSpace,
|
deleteSpace,
|
||||||
getSpace,
|
getSpace,
|
||||||
getSpaceStatements,
|
getSpaceEpisodeCount,
|
||||||
initializeStatementSpaceIds,
|
getSpaceEpisodes,
|
||||||
removeStatementsFromSpace,
|
removeEpisodesFromSpace,
|
||||||
updateSpace,
|
updateSpace,
|
||||||
} from "./graphModels/space";
|
} from "./graphModels/space";
|
||||||
import { prisma } from "~/trigger/utils/prisma";
|
import { prisma } from "~/trigger/utils/prisma";
|
||||||
@ -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({
|
const space = await prisma.space.update({
|
||||||
where: {
|
where: {
|
||||||
id: spaceId,
|
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(
|
async resetSpace(spaceId: string, userId: string): Promise<Space> {
|
||||||
statementIds: string[],
|
logger.info(`Resetting space ${spaceId} for user ${userId}`);
|
||||||
spaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<SpaceAssignmentResult> {
|
|
||||||
logger.info(
|
|
||||||
`Assigning ${statementIds.length} statements to space ${spaceId} for user ${userId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate input
|
// Get the space first to verify it exists and get its details
|
||||||
if (statementIds.length === 0) {
|
const space = await prisma.space.findUnique({
|
||||||
throw new Error("No statement IDs provided");
|
where: {
|
||||||
|
id: spaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!space) {
|
||||||
|
throw new Error("Space not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statementIds.length > 1000) {
|
if (space.name === "Profile") {
|
||||||
throw new Error("Too many statements (max 1000 per operation)");
|
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) {
|
// Recreate the space in Neo4j (clean slate)
|
||||||
logger.info(
|
await createSpace(
|
||||||
`Successfully assigned ${result.statementsUpdated} statements to space ${spaceId}`,
|
space.id,
|
||||||
);
|
space.name.trim(),
|
||||||
} else {
|
space.description?.trim(),
|
||||||
logger.warn(
|
|
||||||
`Failed to assign statements to space ${spaceId}: ${result.error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove statements from a space
|
|
||||||
*/
|
|
||||||
async removeStatementsFromSpace(
|
|
||||||
statementIds: string[],
|
|
||||||
spaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<SpaceAssignmentResult> {
|
|
||||||
logger.info(
|
|
||||||
`Removing ${statementIds.length} statements from space ${spaceId} for user ${userId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (statementIds.length === 0) {
|
|
||||||
throw new Error("No statement IDs provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statementIds.length > 1000) {
|
|
||||||
throw new Error("Too many statements (max 1000 per operation)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await removeStatementsFromSpace(
|
|
||||||
statementIds,
|
|
||||||
spaceId,
|
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
// Reset all summary and metadata fields in PostgreSQL
|
||||||
logger.info(
|
const resetSpace = await prisma.space.update({
|
||||||
`Successfully removed ${result.statementsUpdated} statements from space ${spaceId}`,
|
where: {
|
||||||
);
|
id: spaceId,
|
||||||
} else {
|
},
|
||||||
logger.warn(
|
data: {
|
||||||
`Failed to remove statements from space ${spaceId}: ${result.error}`,
|
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) {
|
async getSpaceEpisodes(spaceId: string, userId: string) {
|
||||||
logger.info(`Fetching statements for space ${spaceId} for user ${userId}`);
|
logger.info(`Fetching episodes for space ${spaceId} for user ${userId}`);
|
||||||
return await getSpaceStatements(spaceId, 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
|
* Validate space access
|
||||||
*/
|
*/
|
||||||
@ -388,41 +351,4 @@ export class SpaceService {
|
|||||||
const space = await this.getSpace(spaceId, userId);
|
const space = await this.getSpace(spaceId, userId);
|
||||||
return space !== null && space.isActive;
|
return space !== null && space.isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk assign statements to multiple spaces
|
|
||||||
*/
|
|
||||||
async bulkAssignStatements(
|
|
||||||
statementIds: string[],
|
|
||||||
spaceIds: string[],
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ spaceId: string; result: SpaceAssignmentResult }[]> {
|
|
||||||
logger.info(
|
|
||||||
`Bulk assigning ${statementIds.length} statements to ${spaceIds.length} spaces for user ${userId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const results: { spaceId: string; result: SpaceAssignmentResult }[] = [];
|
|
||||||
|
|
||||||
for (const spaceId of spaceIds) {
|
|
||||||
try {
|
|
||||||
const result = await this.assignStatementsToSpace(
|
|
||||||
statementIds,
|
|
||||||
spaceId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
results.push({ spaceId, result });
|
|
||||||
} catch (error) {
|
|
||||||
results.push({
|
|
||||||
spaceId,
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
statementsUpdated: 0,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,7 +181,7 @@ export const ingestDocumentTask = task({
|
|||||||
documentUuid: document.uuid,
|
documentUuid: document.uuid,
|
||||||
},
|
},
|
||||||
source: documentBody.source,
|
source: documentBody.source,
|
||||||
spaceId: documentBody.spaceId,
|
spaceIds: documentBody.spaceIds,
|
||||||
sessionId: documentBody.sessionId,
|
sessionId: documentBody.sessionId,
|
||||||
type: EpisodeTypeEnum.DOCUMENT,
|
type: EpisodeTypeEnum.DOCUMENT,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,13 +9,14 @@ import { triggerSpaceAssignment } from "../spaces/space-assignment";
|
|||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { EpisodeType } from "@core/types";
|
import { EpisodeType } from "@core/types";
|
||||||
import { deductCredits, hasCredits } from "../utils/utils";
|
import { deductCredits, hasCredits } from "../utils/utils";
|
||||||
|
import { assignEpisodesToSpace } from "~/services/graphModels/space";
|
||||||
|
|
||||||
export const IngestBodyRequest = z.object({
|
export const IngestBodyRequest = z.object({
|
||||||
episodeBody: z.string(),
|
episodeBody: z.string(),
|
||||||
referenceTime: z.string(),
|
referenceTime: z.string(),
|
||||||
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
metadata: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||||
source: z.string(),
|
source: z.string(),
|
||||||
spaceId: z.string().optional(),
|
spaceIds: z.array(z.string()).optional(),
|
||||||
sessionId: z.string().optional(),
|
sessionId: z.string().optional(),
|
||||||
type: z
|
type: z
|
||||||
.enum([EpisodeType.CONVERSATION, EpisodeType.DOCUMENT])
|
.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 {
|
try {
|
||||||
logger.info(`Triggering space assignment after successful ingestion`, {
|
// If spaceIds were explicitly provided, immediately assign the episode to those spaces
|
||||||
userId: payload.userId,
|
if (episodeBody.spaceIds && episodeBody.spaceIds.length > 0 && episodeDetails.episodeUuid) {
|
||||||
workspaceId: payload.workspaceId,
|
logger.info(`Assigning episode to explicitly provided spaces`, {
|
||||||
episodeId: episodeDetails?.episodeUuid,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
episodeDetails.episodeUuid &&
|
|
||||||
currentStatus === IngestionStatus.COMPLETED
|
|
||||||
) {
|
|
||||||
await triggerSpaceAssignment({
|
|
||||||
userId: payload.userId,
|
userId: payload.userId,
|
||||||
workspaceId: payload.workspaceId,
|
episodeId: episodeDetails.episodeUuid,
|
||||||
mode: "episode",
|
spaceIds: episodeBody.spaceIds,
|
||||||
episodeIds: episodeUuids,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
} catch (assignmentError) {
|
||||||
// Don't fail the ingestion if assignment fails
|
// 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 { getSpace, updateSpace } from "../utils/space-utils";
|
||||||
|
|
||||||
import { EpisodeType } from "@core/types";
|
import { EpisodeType } from "@core/types";
|
||||||
import { getSpaceStatementCount } from "~/services/graphModels/space";
|
import { getSpaceEpisodeCount } from "~/services/graphModels/space";
|
||||||
import { addToQueue } from "../utils/queue";
|
import { addToQueue } from "../utils/queue";
|
||||||
|
|
||||||
interface SpaceSummaryPayload {
|
interface SpaceSummaryPayload {
|
||||||
@ -35,7 +35,7 @@ interface SpaceSummaryData {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
spaceName: string;
|
spaceName: string;
|
||||||
spaceDescription?: string;
|
spaceDescription?: string;
|
||||||
statementCount: number;
|
contextCount: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
keyEntities: string[];
|
keyEntities: string[];
|
||||||
themes: string[];
|
themes: string[];
|
||||||
@ -55,7 +55,7 @@ const SummaryResultSchema = z.object({
|
|||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
maxEpisodesForSummary: 20, // Limit episodes for performance
|
maxEpisodesForSummary: 20, // Limit episodes for performance
|
||||||
minEpisodesForSummary: 1, // Minimum episodes to generate summary
|
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({
|
export const spaceSummaryQueue = queue({
|
||||||
@ -85,7 +85,7 @@ export const spaceSummaryTask = task({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate summary for the single space
|
// Generate summary for the single space
|
||||||
const summaryResult = await generateSpaceSummary(spaceId, userId);
|
const summaryResult = await generateSpaceSummary(spaceId, userId, triggerSource);
|
||||||
|
|
||||||
if (summaryResult) {
|
if (summaryResult) {
|
||||||
// Store the summary
|
// Store the summary
|
||||||
@ -98,36 +98,24 @@ export const spaceSummaryTask = task({
|
|||||||
metadata: {
|
metadata: {
|
||||||
triggerSource,
|
triggerSource,
|
||||||
phase: "completed_summary",
|
phase: "completed_summary",
|
||||||
statementCount: summaryResult.statementCount,
|
contextCount: summaryResult.contextCount,
|
||||||
confidence: summaryResult.confidence,
|
confidence: summaryResult.confidence,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Generated summary for space ${spaceId}`, {
|
logger.info(`Generated summary for space ${spaceId}`, {
|
||||||
statementCount: summaryResult.statementCount,
|
statementCount: summaryResult.contextCount,
|
||||||
confidence: summaryResult.confidence,
|
confidence: summaryResult.confidence,
|
||||||
themes: summaryResult.themes.length,
|
themes: summaryResult.themes.length,
|
||||||
triggerSource,
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
spaceId,
|
spaceId,
|
||||||
triggerSource,
|
triggerSource,
|
||||||
summary: {
|
summary: {
|
||||||
statementCount: summaryResult.statementCount,
|
statementCount: summaryResult.contextCount,
|
||||||
confidence: summaryResult.confidence,
|
confidence: summaryResult.confidence,
|
||||||
themesCount: summaryResult.themes.length,
|
themesCount: summaryResult.themes.length,
|
||||||
},
|
},
|
||||||
@ -186,6 +174,7 @@ export const spaceSummaryTask = task({
|
|||||||
async function generateSpaceSummary(
|
async function generateSpaceSummary(
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
triggerSource?: "assignment" | "manual" | "scheduled",
|
||||||
): Promise<SpaceSummaryData | null> {
|
): Promise<SpaceSummaryData | null> {
|
||||||
try {
|
try {
|
||||||
// 1. Get space details
|
// 1. Get space details
|
||||||
@ -197,6 +186,35 @@ async function generateSpaceSummary(
|
|||||||
return null;
|
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
|
// 2. Check for existing summary
|
||||||
const existingSummary = await getExistingSummary(spaceId);
|
const existingSummary = await getExistingSummary(spaceId);
|
||||||
const isIncremental = existingSummary !== null;
|
const isIncremental = existingSummary !== null;
|
||||||
@ -296,14 +314,14 @@ async function generateSpaceSummary(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the actual current statement count from Neo4j
|
// Get the actual current counts from Neo4j
|
||||||
const currentStatementCount = await getSpaceStatementCount(spaceId, userId);
|
const currentEpisodeCount = await getSpaceEpisodeCount(spaceId, userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaceId: space.uuid,
|
spaceId: space.uuid,
|
||||||
spaceName: space.name,
|
spaceName: space.name,
|
||||||
spaceDescription: space.description as string,
|
spaceDescription: space.description as string,
|
||||||
statementCount: currentStatementCount,
|
contextCount: currentEpisodeCount,
|
||||||
summary: summaryResult.summary,
|
summary: summaryResult.summary,
|
||||||
keyEntities: summaryResult.keyEntities || [],
|
keyEntities: summaryResult.keyEntities || [],
|
||||||
themes: summaryResult.themes,
|
themes: summaryResult.themes,
|
||||||
@ -400,38 +418,48 @@ function createUnifiedSummaryPrompt(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: "system",
|
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:
|
CRITICAL RULES:
|
||||||
1. Base your summary ONLY on insights derived from the actual content/episodes provided
|
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")
|
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
|
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:
|
INSTRUCTIONS:
|
||||||
${
|
${
|
||||||
isUpdate
|
isUpdate
|
||||||
? `1. Review the existing summary and themes carefully
|
? `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
|
3. Identify connecting points between existing knowledge and new episodes
|
||||||
4. Update the summary to seamlessly integrate new information while preserving valuable existing insights
|
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
|
5. Evolve themes by adding new ones or refining existing ones based on the space's purpose
|
||||||
6. Update the markdown summary to reflect the enhanced themes and new insights`
|
6. Organize the summary to serve the space's intended use case`
|
||||||
: `1. Analyze the semantic content and relationships within the episodes
|
: `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)
|
2. Identify topics/sections that align with the space's INTENT and PURPOSE
|
||||||
3. Create a coherent summary that captures the essence of this knowledge domain
|
3. Create a coherent summary that serves the space's intended use case
|
||||||
4. Generate a well-structured markdown summary organized by the identified themes`
|
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:
|
INTENT-ALIGNED ORGANIZATION:
|
||||||
- A theme must be supported by AT LEAST 3 related episodes to be considered valid
|
- Organize sections based on what serves the space's purpose
|
||||||
- Themes should represent substantial, meaningful patterns rather than minor occurrences
|
- Topics don't need minimum episode counts - relevance to intent matters most
|
||||||
- Each theme must capture a distinct semantic domain or conceptual area
|
- Each section should provide value aligned with the space's intended use
|
||||||
- Only identify themes that have sufficient evidence in the data
|
- For "guidelines" spaces: focus on actionable patterns
|
||||||
- If fewer than 3 episodes support a potential theme, do not include it
|
- For "tracking" spaces: focus on temporal patterns and changes
|
||||||
- Themes will be used to organize the markdown summary into logical sections
|
- For "learning" spaces: focus on concepts and insights gained
|
||||||
|
- Let the space's intent drive the structure, not rigid rules
|
||||||
|
|
||||||
${
|
${
|
||||||
isUpdate
|
isUpdate
|
||||||
@ -484,7 +512,7 @@ ${
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: `SPACE INFORMATION:
|
content: `SPACE INFORMATION:
|
||||||
Name: "${spaceName}"
|
Name: "${spaceName}"
|
||||||
Description (for context only): ${spaceDescription || "No description provided"}
|
Intent/Purpose: ${spaceDescription || "No specific intent provided - organize naturally based on content"}
|
||||||
|
|
||||||
${
|
${
|
||||||
isUpdate
|
isUpdate
|
||||||
@ -508,8 +536,8 @@ ${topEntities.join(", ")}`
|
|||||||
|
|
||||||
${
|
${
|
||||||
isUpdate
|
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 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 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 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;
|
summary: string;
|
||||||
themes: string[];
|
themes: string[];
|
||||||
lastUpdated: Date;
|
lastUpdated: Date;
|
||||||
statementCount: number;
|
contextCount: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
const existingSummary = await getSpace(spaceId);
|
const existingSummary = await getSpace(spaceId);
|
||||||
@ -528,8 +556,8 @@ async function getExistingSummary(spaceId: string): Promise<{
|
|||||||
return {
|
return {
|
||||||
summary: existingSummary.summary,
|
summary: existingSummary.summary,
|
||||||
themes: existingSummary.themes,
|
themes: existingSummary.themes,
|
||||||
lastUpdated: existingSummary.lastPatternTrigger || new Date(),
|
lastUpdated: existingSummary.summaryGeneratedAt || new Date(),
|
||||||
statementCount: existingSummary.statementCount || 0,
|
contextCount: existingSummary.contextCount || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,24 +575,18 @@ async function getSpaceEpisodes(
|
|||||||
userId: string,
|
userId: string,
|
||||||
sinceDate?: Date,
|
sinceDate?: Date,
|
||||||
): Promise<SpaceEpisodeData[]> {
|
): Promise<SpaceEpisodeData[]> {
|
||||||
// Build query to get distinct episodes that have statements in the space
|
// Query episodes directly using Space-[:HAS_EPISODE]->Episode relationships
|
||||||
let whereClause =
|
|
||||||
"s.spaceIds IS NOT NULL AND $spaceId IN s.spaceIds AND s.invalidAt IS NULL";
|
|
||||||
const params: any = { spaceId, userId };
|
const params: any = { spaceId, userId };
|
||||||
|
|
||||||
// Store the sinceDate condition separately to apply after e is defined
|
|
||||||
let dateCondition = "";
|
let dateCondition = "";
|
||||||
if (sinceDate) {
|
if (sinceDate) {
|
||||||
dateCondition = "e.createdAt > $sinceDate";
|
dateCondition = "AND e.createdAt > $sinceDate";
|
||||||
params.sinceDate = sinceDate.toISOString();
|
params.sinceDate = sinceDate.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
MATCH (s:Statement{userId: $userId})
|
MATCH (space:Space {uuid: $spaceId, userId: $userId})-[:HAS_EPISODE]->(e:Episode {userId: $userId})
|
||||||
WHERE ${whereClause}
|
WHERE e IS NOT NULL ${dateCondition}
|
||||||
OPTIONAL MATCH (e:Episode{userId: $userId})-[:HAS_PROVENANCE]->(s)
|
|
||||||
WITH e
|
|
||||||
WHERE e IS NOT NULL ${dateCondition ? `AND ${dateCondition}` : ""}
|
|
||||||
RETURN DISTINCT e
|
RETURN DISTINCT e
|
||||||
ORDER BY e.createdAt DESC
|
ORDER BY e.createdAt DESC
|
||||||
`;
|
`;
|
||||||
@ -654,7 +676,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
|||||||
space.keyEntities = $keyEntities,
|
space.keyEntities = $keyEntities,
|
||||||
space.themes = $themes,
|
space.themes = $themes,
|
||||||
space.summaryConfidence = $confidence,
|
space.summaryConfidence = $confidence,
|
||||||
space.summaryStatementCount = $statementCount,
|
space.summaryContextCount = $contextCount,
|
||||||
space.summaryLastUpdated = datetime($lastUpdated)
|
space.summaryLastUpdated = datetime($lastUpdated)
|
||||||
RETURN space
|
RETURN space
|
||||||
`;
|
`;
|
||||||
@ -665,7 +687,7 @@ async function storeSummary(summaryData: SpaceSummaryData): Promise<void> {
|
|||||||
keyEntities: summaryData.keyEntities,
|
keyEntities: summaryData.keyEntities,
|
||||||
themes: summaryData.themes,
|
themes: summaryData.themes,
|
||||||
confidence: summaryData.confidence,
|
confidence: summaryData.confidence,
|
||||||
statementCount: summaryData.statementCount,
|
contextCount: summaryData.contextCount,
|
||||||
lastUpdated: summaryData.lastUpdated.toISOString(),
|
lastUpdated: summaryData.lastUpdated.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export const updateSpace = async (summaryData: {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
themes: string[];
|
themes: string[];
|
||||||
statementCount: number;
|
contextCount: number;
|
||||||
}) => {
|
}) => {
|
||||||
return await prisma.space.update({
|
return await prisma.space.update({
|
||||||
where: {
|
where: {
|
||||||
@ -40,7 +40,8 @@ export const updateSpace = async (summaryData: {
|
|||||||
data: {
|
data: {
|
||||||
summary: summaryData.summary,
|
summary: summaryData.summary,
|
||||||
themes: summaryData.themes,
|
themes: summaryData.themes,
|
||||||
statementCount: summaryData.statementCount,
|
contextCount: summaryData.contextCount,
|
||||||
|
summaryGeneratedAt: new Date().toISOString()
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -201,6 +201,45 @@ export class IntegrationLoader {
|
|||||||
return allTools;
|
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
|
* Call a tool on a specific integration
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { addToQueue } from "~/lib/ingest.server";
|
|||||||
import { logger } from "~/services/logger.service";
|
import { logger } from "~/services/logger.service";
|
||||||
import { SearchService } from "~/services/search.server";
|
import { SearchService } from "~/services/search.server";
|
||||||
import { SpaceService } from "~/services/space.server";
|
import { SpaceService } from "~/services/space.server";
|
||||||
|
import { IntegrationLoader } from "./integration-loader";
|
||||||
|
|
||||||
const searchService = new SearchService();
|
const searchService = new SearchService();
|
||||||
const spaceService = new SpaceService();
|
const spaceService = new SpaceService();
|
||||||
@ -13,29 +14,31 @@ const SearchParamsSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: "string",
|
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: {
|
validAt: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
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: {
|
startTime: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
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: {
|
endTime: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
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: {
|
spaceIds: {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: {
|
items: {
|
||||||
type: "string",
|
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"],
|
required: ["query"],
|
||||||
@ -46,7 +49,16 @@ const IngestSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
message: {
|
message: {
|
||||||
type: "string",
|
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"],
|
required: ["message"],
|
||||||
@ -56,25 +68,26 @@ export const memoryTools = [
|
|||||||
{
|
{
|
||||||
name: "memory_ingest",
|
name: "memory_ingest",
|
||||||
description:
|
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,
|
inputSchema: IngestSchema,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "memory_search",
|
name: "memory_search",
|
||||||
description:
|
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,
|
inputSchema: SearchParamsSchema,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "memory_get_spaces",
|
name: "memory_get_spaces",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
all: {
|
all: {
|
||||||
type: "boolean",
|
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",
|
name: "memory_about_user",
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
profile: {
|
profile: {
|
||||||
type: "boolean",
|
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
|
// Function to call memory tools based on toolName
|
||||||
@ -112,6 +197,14 @@ export async function callMemoryTool(
|
|||||||
return await handleMemoryGetSpaces(userId);
|
return await handleMemoryGetSpaces(userId);
|
||||||
case "memory_about_user":
|
case "memory_about_user":
|
||||||
return await handleUserProfile(userId);
|
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:
|
default:
|
||||||
throw new Error(`Unknown memory tool: ${toolName}`);
|
throw new Error(`Unknown memory tool: ${toolName}`);
|
||||||
}
|
}
|
||||||
@ -160,12 +253,17 @@ async function handleUserProfile(userId: string) {
|
|||||||
// Handler for memory_ingest
|
// Handler for memory_ingest
|
||||||
async function handleMemoryIngest(args: any) {
|
async function handleMemoryIngest(args: any) {
|
||||||
try {
|
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(
|
const response = await addToQueue(
|
||||||
{
|
{
|
||||||
episodeBody: args.message,
|
episodeBody: args.message,
|
||||||
referenceTime: new Date().toISOString(),
|
referenceTime: new Date().toISOString(),
|
||||||
source: args.source,
|
source: args.source,
|
||||||
type: EpisodeTypeEnum.CONVERSATION,
|
type: EpisodeTypeEnum.CONVERSATION,
|
||||||
|
spaceIds,
|
||||||
},
|
},
|
||||||
args.userId,
|
args.userId,
|
||||||
);
|
);
|
||||||
@ -198,12 +296,17 @@ async function handleMemoryIngest(args: any) {
|
|||||||
// Handler for memory_search
|
// Handler for memory_search
|
||||||
async function handleMemorySearch(args: any) {
|
async function handleMemorySearch(args: any) {
|
||||||
try {
|
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(
|
const results = await searchService.search(
|
||||||
args.query,
|
args.query,
|
||||||
args.userId,
|
args.userId,
|
||||||
{
|
{
|
||||||
startTime: args.startTime ? new Date(args.startTime) : undefined,
|
startTime: args.startTime ? new Date(args.startTime) : undefined,
|
||||||
endTime: args.endTime ? new Date(args.endTime) : undefined,
|
endTime: args.endTime ? new Date(args.endTime) : undefined,
|
||||||
|
spaceIds,
|
||||||
},
|
},
|
||||||
args.source,
|
args.source,
|
||||||
);
|
);
|
||||||
@ -235,11 +338,17 @@ async function handleMemoryGetSpaces(userId: string) {
|
|||||||
try {
|
try {
|
||||||
const spaces = await spaceService.getUserSpaces(userId);
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(spaces),
|
text: JSON.stringify(simplifiedSpaces),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: false,
|
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-dom": "^18.2.0",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-resizable-panels": "^1.0.9",
|
"react-resizable-panels": "^1.0.9",
|
||||||
|
"react-hotkeys-hook": "^4.5.0",
|
||||||
"react-virtualized": "^9.22.6",
|
"react-virtualized": "^9.22.6",
|
||||||
"remix-auth": "^4.2.0",
|
"remix-auth": "^4.2.0",
|
||||||
"remix-auth-oauth2": "^3.4.1",
|
"remix-auth-oauth2": "^3.4.1",
|
||||||
@ -135,6 +136,7 @@
|
|||||||
"stripe": "19.0.0",
|
"stripe": "19.0.0",
|
||||||
"simple-oauth2": "^5.1.0",
|
"simple-oauth2": "^5.1.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tiptap-markdown": "0.9.0",
|
||||||
"tailwind-scrollbar-hide": "^2.0.0",
|
"tailwind-scrollbar-hide": "^2.0.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-textshadow": "^2.1.3",
|
"tailwindcss-textshadow": "^2.1.3",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
VERSION=0.1.23
|
VERSION=0.1.24
|
||||||
|
|
||||||
# Nest run in docker, change host to database container name
|
# Nest run in docker, change host to database container name
|
||||||
DB_HOST=postgres
|
DB_HOST=postgres
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "core",
|
"name": "core",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.23",
|
"version": "0.1.24",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"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 {
|
model IngestionQueue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
// Relations
|
|
||||||
space Space? @relation(fields: [spaceId], references: [id])
|
|
||||||
spaceId String?
|
|
||||||
|
|
||||||
// Queue metadata
|
// Queue metadata
|
||||||
data Json // The actual data to be processed
|
data Json // The actual data to be processed
|
||||||
output Json? // The processed output data
|
output Json? // The processed output data
|
||||||
@ -472,20 +468,21 @@ model RecallLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Space {
|
model Space {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
autoMode Boolean @default(false)
|
autoMode Boolean @default(false)
|
||||||
summary String?
|
summary String?
|
||||||
themes String[]
|
themes String[]
|
||||||
statementCount Int?
|
contextCount Int? // Count of context items in this space (episodes, statements, etc.)
|
||||||
|
|
||||||
status String?
|
status String?
|
||||||
|
|
||||||
icon String?
|
icon String?
|
||||||
|
|
||||||
lastPatternTrigger DateTime?
|
lastPatternTrigger DateTime?
|
||||||
statementCountAtLastTrigger Int?
|
summaryGeneratedAt DateTime?
|
||||||
|
contextCountAtLastTrigger Int? // Context count when pattern was last triggered
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||||
@ -493,7 +490,6 @@ model Space {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
IngestionQueue IngestionQueue[]
|
|
||||||
SpacePattern SpacePattern[]
|
SpacePattern SpacePattern[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export interface EpisodicNode {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
recallCount?: number;
|
recallCount?: number;
|
||||||
chunkIndex?: number; // Index of this chunk within the document
|
chunkIndex?: number; // Index of this chunk within the document
|
||||||
|
spaceIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface SpaceNode {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
statementCount?: number; // Computed field
|
contextCount?: number; // Computed field - count of episodes assigned to this space
|
||||||
embedding?: number[]; // For future space similarity
|
embedding?: number[]; // For future space similarity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@ -607,6 +607,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-hotkeys-hook:
|
||||||
|
specifier: ^4.5.0
|
||||||
|
version: 4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: 10.1.0
|
specifier: 10.1.0
|
||||||
version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
|
version: 10.1.0(@types/react@18.2.69)(react@18.3.1)
|
||||||
@ -655,6 +658,9 @@ importers:
|
|||||||
tiny-invariant:
|
tiny-invariant:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.3
|
version: 1.3.3
|
||||||
|
tiptap-markdown:
|
||||||
|
specifier: 0.9.0
|
||||||
|
version: 0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0))
|
||||||
zod:
|
zod:
|
||||||
specifier: 3.25.76
|
specifier: 3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@ -5741,9 +5747,15 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
|
'@types/linkify-it@3.0.5':
|
||||||
|
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0':
|
'@types/linkify-it@5.0.0':
|
||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
|
'@types/markdown-it@13.0.9':
|
||||||
|
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
|
|
||||||
@ -5753,6 +5765,9 @@ packages:
|
|||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
|
'@types/mdurl@1.0.5':
|
||||||
|
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
|
||||||
|
|
||||||
'@types/mdurl@2.0.0':
|
'@types/mdurl@2.0.0':
|
||||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||||
|
|
||||||
@ -9066,6 +9081,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
|
resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
markdown-it-task-lists@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||||
|
|
||||||
markdown-it@14.1.0:
|
markdown-it@14.1.0:
|
||||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -10584,6 +10602,12 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
react-hotkeys-hook@4.6.2:
|
||||||
|
resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.1'
|
||||||
|
react-dom: '>=16.8.1'
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@ -11556,6 +11580,11 @@ packages:
|
|||||||
tiptap-extension-global-drag-handle@0.1.18:
|
tiptap-extension-global-drag-handle@0.1.18:
|
||||||
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
|
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
|
||||||
|
|
||||||
|
tiptap-markdown@0.9.0:
|
||||||
|
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^3.0.1
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
||||||
engines: {node: '>=0.6.0'}
|
engines: {node: '>=0.6.0'}
|
||||||
@ -18728,8 +18757,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
|
'@types/linkify-it@3.0.5': {}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
|
'@types/markdown-it@13.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/linkify-it': 3.0.5
|
||||||
|
'@types/mdurl': 1.0.5
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/linkify-it': 5.0.0
|
'@types/linkify-it': 5.0.0
|
||||||
@ -18743,6 +18779,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/mdurl@1.0.5': {}
|
||||||
|
|
||||||
'@types/mdurl@2.0.0': {}
|
'@types/mdurl@2.0.0': {}
|
||||||
|
|
||||||
'@types/mdx@2.0.13': {}
|
'@types/mdx@2.0.13': {}
|
||||||
@ -22608,6 +22646,8 @@ snapshots:
|
|||||||
|
|
||||||
markdown-extensions@1.1.1: {}
|
markdown-extensions@1.1.1: {}
|
||||||
|
|
||||||
|
markdown-it-task-lists@2.1.1: {}
|
||||||
|
|
||||||
markdown-it@14.1.0:
|
markdown-it@14.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
@ -24536,6 +24576,11 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- webpack-cli
|
- webpack-cli
|
||||||
|
|
||||||
|
react-hotkeys-hook@4.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
@ -25790,6 +25835,14 @@ snapshots:
|
|||||||
|
|
||||||
tiptap-extension-global-drag-handle@0.1.18: {}
|
tiptap-extension-global-drag-handle@0.1.18: {}
|
||||||
|
|
||||||
|
tiptap-markdown@0.9.0(@tiptap/core@2.25.0(@tiptap/pm@2.25.0)):
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.25.0(@tiptap/pm@2.25.0)
|
||||||
|
'@types/markdown-it': 13.0.9
|
||||||
|
markdown-it: 14.1.0
|
||||||
|
markdown-it-task-lists: 2.1.1
|
||||||
|
prosemirror-markdown: 1.13.2
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
dependencies:
|
dependencies:
|
||||||
os-tmpdir: 1.0.2
|
os-tmpdir: 1.0.2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user