Feat: added conversation

This commit is contained in:
Harshith Mullapudi 2025-07-08 22:33:14 +05:30
parent 6ef523520d
commit 0767268770
46 changed files with 3698 additions and 121 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
import { EditorContent, useEditor } from "@tiptap/react";
import React, { useEffect } from "react";
import { Document } from "@tiptap/extension-document";
import HardBreak from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { UserTypeEnum } from "@core/types";
import { type ConversationHistory } from "@core/database";
import { cn } from "~/lib/utils";
interface AIConversationItemProps {
conversationHistory: ConversationHistory;
}
export const ConversationItem = ({
conversationHistory,
}: AIConversationItemProps) => {
const isUser =
conversationHistory.userType === UserTypeEnum.User ||
conversationHistory.userType === UserTypeEnum.System;
const id = `a${conversationHistory.id.replace(/-/g, "")}`;
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
],
editable: false,
content: conversationHistory.message,
});
useEffect(() => {
editor?.commands.setContent(conversationHistory.message);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, conversationHistory.message]);
if (!conversationHistory.message) {
return null;
}
return (
<div className={cn("flex gap-2 px-4 pb-2", isUser && "my-4 justify-end")}>
<div
className={cn(
"flex flex-col",
isUser && "bg-primary/20 max-w-[500px] rounded-md p-3",
)}
>
<EditorContent editor={editor} />
</div>
</div>
);
};

View File

@ -0,0 +1,245 @@
import { useFetcher } from "@remix-run/react";
import { useEffect, useState, useCallback, useRef } from "react";
import {
List,
AutoSizer,
InfiniteLoader,
type ListRowRenderer,
} from "react-virtualized";
import { format } from "date-fns";
import { MessageSquare, Clock } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "../ui";
type ConversationItem = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
unread: boolean;
status: string;
ConversationHistory: Array<{
id: string;
message: string;
userType: string;
createdAt: string;
}>;
};
type ConversationListResponse = {
conversations: ConversationItem[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
};
export const ConversationList = ({
currentConversationId,
}: {
currentConversationId?: string;
}) => {
const fetcher = useFetcher<ConversationListResponse>();
const [conversations, setConversations] = useState<ConversationItem[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [hasNextPage, setHasNextPage] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// const [searchTerm, setSearchTerm] = useState("");
// const searchTimeoutRef = useRef<NodeJS.Timeout>();
const loadMoreConversations = useCallback(
(page: number) => {
if (isLoading) return;
setIsLoading(true);
const searchParams = new URLSearchParams({
page: page.toString(),
limit: "25",
});
fetcher.load(`/api/v1/conversations?${searchParams}`, {
flushSync: true,
});
},
[isLoading, fetcher, currentPage],
);
useEffect(() => {
loadMoreConversations(1);
}, []);
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
setIsLoading(false);
const response = fetcher.data;
if (currentPage === 1) {
setConversations(response.conversations);
} else {
setConversations((prev) => [...prev, ...response.conversations]);
}
setHasNextPage(response.pagination.hasNext);
setCurrentPage(response.pagination.page);
}
}, [fetcher.data, fetcher.state, currentPage]);
// const handleSearch = useCallback(
// (term: string) => {
// setSearchTerm(term);
// setCurrentPage(1);
// setConversations([]);
// setHasNextPage(true);
// if (searchTimeoutRef.current) {
// clearTimeout(searchTimeoutRef.current);
// }
// searchTimeoutRef.current = setTimeout(() => {
// loadMoreConversations(1);
// }, 300);
// },
// [loadMoreConversations],
// );
const isRowLoaded = useCallback(
({ index }: { index: number }) => {
return !!conversations[index];
},
[conversations],
);
const loadMoreRows = useCallback(() => {
if (!hasNextPage || isLoading) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
if (conversations.length === 25) {
const nextPage = currentPage + 1;
loadMoreConversations(nextPage);
const checkLoaded = () => {
if (!isLoading) {
resolve();
} else {
setTimeout(checkLoaded, 100);
}
};
checkLoaded();
}
});
}, [
hasNextPage,
isLoading,
currentPage,
loadMoreConversations,
conversations,
]);
const rowRenderer: ListRowRenderer = useCallback(
({ index, key, style }) => {
const conversation = conversations[index];
if (!conversation) {
return (
<div key={key} style={style}>
<div className="flex items-center justify-center p-4">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
</div>
</div>
);
}
return (
<div key={key} style={style}>
<div className="p-2">
<Button
variant="ghost"
className={cn(
"border-border h-auto w-full justify-start p-2 text-left",
currentConversationId === conversation.id && "bg-grayAlpha-100",
)}
onClick={() => {
window.location.href = `/home/conversation/${conversation.id}`;
}}
>
<div className="flex w-full items-start space-x-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={cn("truncate font-normal")}>
{conversation.title || "Untitled Conversation"}
</p>
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
<span>
{format(new Date(conversation.updatedAt), "MMM d")}
</span>
</div>
</div>
</div>
</div>
</Button>
</div>
</div>
);
},
[conversations],
);
const rowCount = hasNextPage
? conversations.length + 1
: conversations.length;
return (
<div className="flex h-full flex-col">
{/* <div className="border-b">
<Input
type="text"
placeholder="Search conversations..."
className="focus:ring-primary w-full rounded-none px-3 py-2 focus:ring-2 focus:outline-none"
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
/>
</div> */}
<div className="flex-1 overflow-hidden">
<InfiniteLoader
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={rowCount}
threshold={5}
>
{({ onRowsRendered, registerChild }) => (
<AutoSizer>
{({ height, width }) => (
<List
ref={registerChild}
height={height}
width={width}
rowCount={rowCount}
rowHeight={40}
onRowsRendered={onRowsRendered}
rowRenderer={rowRenderer}
overscanRowCount={5}
/>
)}
</AutoSizer>
)}
</InfiniteLoader>
</div>
{isLoading && conversations.length === 0 && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2">
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-muted-foreground text-sm">
Loading conversations...
</span>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,175 @@
import { Document } from "@tiptap/extension-document";
import HardBreak from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { type Editor } from "@tiptap/react";
import { EditorContent, Placeholder, EditorRoot } from "novel";
import { useCallback, useState } from "react";
import { cn } from "~/lib/utils";
import { Button } from "../ui";
import { Loader } from "lucide-react";
import { Form, useSubmit } from "@remix-run/react";
interface ConversationTextareaProps {
defaultValue?: string;
conversationId: string;
placeholder?: string;
isLoading?: boolean;
className?: string;
onChange?: (text: string) => void;
disabled?: boolean;
}
export function ConversationTextarea({
defaultValue,
isLoading = false,
placeholder,
className,
conversationId,
onChange,
}: ConversationTextareaProps) {
const [text, setText] = useState(defaultValue ?? "");
const [editor, setEditor] = useState<Editor>();
const submit = useSubmit();
const onUpdate = (editor: Editor) => {
setText(editor.getHTML());
onChange && onChange(editor.getText());
};
const handleSend = useCallback(() => {
if (!editor || !text) {
return;
}
const data = isLoading
? {}
: { message: text, title: text, conversationId };
submit(data as any, {
action: isLoading
? `/home/conversation/${conversationId}`
: "/home/conversation",
method: "post",
});
editor?.commands.clearContent(true);
setText("");
editor.commands.clearContent(true);
setText("");
}, [editor, text]);
// Send message to API
const submitForm = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
const data = isLoading
? {}
: { message: text, title: text, conversationId };
submit(data as any, {
action: isLoading
? `/home/conversation/${conversationId}`
: "/home/conversation",
method: "post",
});
editor?.commands.clearContent(true);
setText("");
e.preventDefault();
},
[text, conversationId],
);
return (
<Form
action="/home/conversation"
method="post"
onSubmit={(e) => submitForm(e)}
className="pt-2"
>
<div className="bg-background-3 rounded py-2">
<EditorRoot>
<EditorContent
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialContent={defaultValue as any}
extensions={[
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
Placeholder.configure({
placeholder: () => placeholder ?? "Ask sol...",
includeChildren: true,
}),
History,
]}
onCreate={async ({ editor }) => {
setEditor(editor);
await new Promise((resolve) => setTimeout(resolve, 100));
editor.commands.focus("end");
}}
onUpdate={({ editor }) => {
onUpdate(editor);
}}
shouldRerenderOnTransaction={false}
editorProps={{
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
handleKeyDown(view, event) {
if (event.key === "Enter" && !event.shiftKey) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const target = event.target as any;
if (target.innerHTML.includes("suggestion")) {
return false;
}
event.preventDefault();
if (text) {
handleSend();
}
return true;
}
if (event.key === "Enter" && event.shiftKey) {
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.hardBreak.create(),
),
);
return true;
}
return false;
},
}}
immediatelyRender={false}
className={cn(
"editor-container text-md max-h-[400px] min-h-[40px] w-full min-w-full overflow-auto px-3 sm:rounded-lg",
)}
/>
</EditorRoot>
<div className="flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
type="submit"
size="lg"
>
{isLoading ? (
<>
<Loader size={18} className="mr-1 animate-spin" />
Stop
</>
) : (
<>Chat</>
)}
</Button>
</div>
</div>
</Form>
);
}

View File

@ -0,0 +1,151 @@
import { EditorRoot, EditorContent, Placeholder } from "novel";
import { useState, useRef, useCallback } from "react";
import { Form, useNavigate, useSubmit } from "@remix-run/react";
import { cn } from "~/lib/utils";
import { Document } from "@tiptap/extension-document";
import HardBreak from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Button } from "../ui";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../ui/resizable";
import { ConversationList } from "./conversation-list";
export const ConversationNew = ({
user,
}: {
user: { name: string | null };
}) => {
const [content, setContent] = useState("");
const editorRef = useRef<any>(null);
const submit = useSubmit();
// Send message to API
const submitForm = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
if (!content.trim()) return;
submit(
{ message: content, title: content },
{
action: "/home/conversation",
method: "post",
},
);
e.preventDefault();
},
[content],
);
return (
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
<ResizablePanel
maxSize={50}
defaultSize={16}
minSize={16}
collapsible
collapsedSize={16}
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
>
<ConversationList />
</ResizablePanel>
<ResizableHandle className="w-1" />
<ResizablePanel
collapsible
collapsedSize={0}
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
>
<Form
action="/home/conversation"
method="post"
onSubmit={(e) => submitForm(e)}
className="pt-2"
>
<div className={cn("flex h-[calc(100vh_-_60px)] flex-col")}>
<div className="flex h-full w-full flex-col items-start justify-start overflow-y-auto p-4">
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[90ch]">
<h1 className="mx-1 mb-4 text-left text-[32px] font-medium">
Hello <span className="text-primary">{user.name}</span>
</h1>
<div className="bg-background-3 border-border rounded-lg border-1 py-2">
<EditorRoot>
<EditorContent
ref={editorRef}
autofocus
extensions={[
Placeholder.configure({
placeholder: () => {
return "Ask sol...";
},
includeChildren: true,
}),
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
History,
]}
editorProps={{
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
handleKeyDown: (_view: any, event: KeyboardEvent) => {
// This is the ProseMirror event, not React's
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (content) {
submit(
{ message: content, title: content },
{
action: "/home/conversation",
method: "post",
},
);
setContent("");
}
return true;
}
return false;
},
}}
immediatelyRender={false}
className={cn(
"editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg",
)}
onUpdate={({ editor }: { editor: any }) => {
const html = editor.getHTML();
setContent(html);
}}
/>
</EditorRoot>
<div className="flex justify-end px-3">
<Button
variant="default"
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
type="submit"
size="lg"
>
Chat
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</Form>
</ResizablePanel>
</ResizablePanelGroup>
);
};

View File

@ -0,0 +1,5 @@
export * from "./conversation.client";
export * from "./conversation-item.client";
export * from "./streaming-conversation.client";
export * from "./conversation-textarea.client";
export * from "./conversation-list";

View File

@ -0,0 +1,106 @@
import { EditorContent, useEditor } from "@tiptap/react";
import React from "react";
import { Document } from "@tiptap/extension-document";
import HardBreak from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { useTriggerStream } from "./use-trigger-stream";
import { Placeholder } from "novel";
interface StreamingConversationProps {
runId: string;
token: string;
afterStreaming: () => void;
apiURL: string;
}
export const StreamingConversation = ({
runId,
token,
afterStreaming,
apiURL,
}: StreamingConversationProps) => {
const { message, isEnd } = useTriggerStream(runId, token, apiURL);
const [loadingText, setLoadingText] = React.useState("Thinking...");
const loadingMessages = [
"Thinking...",
"Still thinking...",
"Deep in thought...",
"Processing at light speed...",
"Loading SOL...",
"Establishing Mars connection...",
"Consulting the Martian archives...",
"Calculating in Mars time...",
"Warming up the quantum processors...",
"Checking atmospheric conditions on Mars...",
"Untangling red planet algorithms...",
"Just need my Mars-roasted coffee...",
];
const messagesEditor = useEditor({
extensions: [
Placeholder,
Document,
Paragraph,
Text,
HardBreak.configure({
keepMarks: true,
}),
],
editable: false,
content: "",
});
React.useEffect(() => {
if (message) {
messagesEditor?.commands.setContent(message);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message]);
React.useEffect(() => {
if (isEnd) {
afterStreaming();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEnd]);
React.useEffect(() => {
let currentIndex = 0;
let delay = 5000; // Start with 2 seconds for more thinking time
const updateLoadingText = () => {
if (!message) {
setLoadingText(loadingMessages[currentIndex]);
currentIndex = (currentIndex + 1) % loadingMessages.length;
delay = Math.min(delay * 1.3, 8000); // Increase delay more gradually
setTimeout(updateLoadingText, delay);
}
};
const timer = setTimeout(updateLoadingText, delay);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message]);
return (
<div className="flex gap-2 px-5 py-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
{message ? (
<EditorContent
editor={messagesEditor}
className="text-foreground"
/>
) : (
<div className="text-foreground italic">{loadingText}</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,103 @@
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
import React from "react";
export const useTriggerStream = (
runId: string,
token: string,
apiURL?: string,
) => {
const { error, streams, run } = useRealtimeRunWithStreams(runId, {
accessToken: token,
baseURL: apiURL ?? "https://trigger.heysol.ai", // Optional if you are using a self-hosted Trigger.dev instance
});
const isEnd = React.useMemo(() => {
if (error) {
return true;
}
if (
run &&
[
"COMPLETED",
"CANCELED",
"FAILED",
"CRASHED",
"INTERRUPTED",
"SYSTEM_FAILURE",
"EXPIRED",
"TIMED_OUT",
].includes(run?.status)
) {
return true;
}
const hasStreamEnd =
streams.messages &&
streams.messages.filter((item) => {
// Check if the item has a type that includes 'MESSAGE_' and is not empty
return item.type?.includes("STREAM_END");
});
if (hasStreamEnd && hasStreamEnd.length > 0) {
return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [run?.status, error, streams.messages?.length]);
const message = React.useMemo(() => {
if (!streams?.messages) {
return "";
}
// Filter and combine all message chunks
return streams.messages
.filter((item) => {
// Check if the item has a type that includes 'MESSAGE_' and is not empty
return item.type?.includes("MESSAGE_");
})
.map((item) => item.message)
.join("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streams.messages?.length]);
const actionMessages = React.useMemo(() => {
if (!streams?.messages) {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: Record<string, { isStreaming: boolean; content: any[] }> =
{};
streams.messages.forEach((item) => {
if (item.type?.includes("SKILL_")) {
try {
const parsed = JSON.parse(item.message);
const skillId = parsed.skillId;
if (!messages[skillId]) {
messages[skillId] = { isStreaming: true, content: [] };
}
if (item.type === "SKILL_END") {
messages[skillId].isStreaming = false;
}
messages[skillId].content.push(parsed);
} catch (e) {
console.error("Failed to parse message:", e);
}
}
});
return messages;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streams.messages?.length]);
return { isEnd, message, actionMessages };
};

View File

@ -1,5 +1,5 @@
import { type GraphVisualizationProps } from "./graph-visualization";
import { useState, useMemo, forwardRef, useRef, useEffect } from "react";
import { useState, useEffect } from "react";
export function GraphVisualizationClient(props: GraphVisualizationProps) {
const [Component, setComponent] = useState<any>(undefined);

View File

@ -11,7 +11,6 @@ import GraphologyGraph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import noverlap from "graphology-layout-noverlap";
import colors from "tailwindcss/colors";
import ForceSupervisor from "graphology-layout-force/worker";
import type { GraphTriplet, IdValue, GraphNode } from "./type";
import {
createLabelColorMap,
@ -142,7 +141,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
nodeMap.set(triplet.source.id, {
id: triplet.source.id,
label: triplet.source.value,
size: 10,
size: 5,
color: getNodeColor(triplet.source),
x: width,
y: height,
@ -153,7 +152,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
nodeMap.set(triplet.target.id, {
id: triplet.target.id,
label: triplet.target.value,
size: 10,
size: 5,
color: getNodeColor(triplet.target),
x: width,
y: height,
@ -298,10 +297,10 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
graph.setNodeAttribute(node, "y", height);
});
const layout = new ForceSupervisor(graph, {
isNodeFixed: (_, attr) => attr.highlighted,
});
layout.start();
// const layout = new ForceSupervisor(graph, {
// isNodeFixed: (_, attr) => attr.highlighted,
// });
// layout.start();
const settings = forceAtlas2.inferSettings(graph);
forceAtlas2.assign(graph, {
@ -309,7 +308,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
settings: {
...settings,
barnesHutOptimize: true,
strongGravityMode: true,
strongGravityMode: false,
gravity: 0.05,
scalingRatio: 10,
slowDown: 5,
@ -332,7 +331,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
defaultEdgeColor: theme.link.stroke,
defaultNodeColor: theme.node.fill,
enableEdgeEvents: true,
minCameraRatio: 0.5,
minCameraRatio: 0.1,
maxCameraRatio: 2,
});

View File

@ -3,15 +3,13 @@ import Logo from "../logo/logo";
import { Theme, useTheme } from "remix-themes";
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
const [, setTheme] = useTheme();
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex w-full max-w-sm flex-col items-center gap-2">
<div className="flex size-10 items-center justify-center rounded-md">
<Logo width={60} height={60} />
</div>
<a href="#" className="flex items-center gap-2 self-center font-medium">
<div className="bg-background-3 flex size-6 items-center justify-center rounded-md">
<Logo width={20} height={20} />
</div>
<div className="font-mono">C.O.R.E.</div>
</a>
{children}

View File

@ -17,8 +17,8 @@ import Logo from "../logo/logo";
const data = {
navMain: [
{
title: "Chat",
url: "/home/chat",
title: "Conversation",
url: "/home/conversation",
icon: MessageSquare,
},
{

View File

@ -2,7 +2,7 @@ import { useLocation } from "@remix-run/react";
const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
"/home/chat": "Chat",
"/home/conversation": "Conversation",
"/home/integrations": "Integrations",
"/home/activity": "Activity",
};

View File

@ -0,0 +1,169 @@
// @hidden
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils";
interface ScrollState {
isAtBottom: boolean;
autoScrollEnabled: boolean;
}
interface UseAutoScrollOptions {
offset?: number;
smooth?: boolean;
content?: React.ReactNode;
}
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
const { offset = 20, smooth = false, content } = options;
const scrollRef = useRef<HTMLDivElement>(null);
const lastContentHeight = useRef(0);
const userHasScrolled = useRef(false);
const [scrollState, setScrollState] = useState<ScrollState>({
isAtBottom: false,
autoScrollEnabled: true,
});
const checkIsAtBottom = useCallback(
(element: HTMLElement) => {
const { scrollTop, scrollHeight, clientHeight } = element;
const distanceToBottom = Math.abs(
scrollHeight - scrollTop - clientHeight,
);
return distanceToBottom <= offset;
},
[offset],
);
const scrollToBottom = useCallback(
(instant?: boolean) => {
if (scrollRef.current) {
const targetScrollTop =
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
if (instant) {
scrollRef.current.scrollTop = targetScrollTop;
} else {
scrollRef.current.scrollTo({
top: targetScrollTop,
behavior: smooth ? "smooth" : "auto",
});
}
setScrollState({
isAtBottom: true,
autoScrollEnabled: true,
});
userHasScrolled.current = false;
}
},
[smooth],
);
const handleScroll = useCallback(() => {
if (scrollRef.current) {
const atBottom = checkIsAtBottom(scrollRef.current);
setScrollState((prev) => ({
isAtBottom: atBottom,
// Re-enable auto-scroll if at the bottom
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
}));
}
}, [checkIsAtBottom]);
useEffect(() => {
const element = scrollRef.current;
if (element) {
element.addEventListener("scroll", handleScroll, { passive: true });
}
return () =>
element ? element.removeEventListener("scroll", handleScroll) : undefined;
}, [handleScroll]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) {
return;
}
const currentHeight = scrollElement.scrollHeight;
const hasNewContent = currentHeight !== lastContentHeight.current;
if (hasNewContent) {
if (scrollState.autoScrollEnabled) {
requestAnimationFrame(() => {
scrollToBottom(lastContentHeight.current === 0);
});
}
lastContentHeight.current = currentHeight;
}
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (scrollState.autoScrollEnabled) {
scrollToBottom(true);
}
});
const element = scrollRef.current;
if (element) {
resizeObserver.observe(element);
}
return () => resizeObserver.disconnect();
}, [scrollState.autoScrollEnabled, scrollToBottom]);
const disableAutoScroll = useCallback(() => {
const atBottom = scrollRef.current
? checkIsAtBottom(scrollRef.current)
: false;
// Only disable if not at bottom
if (!atBottom) {
userHasScrolled.current = true;
setScrollState((prev) => ({
...prev,
autoScrollEnabled: false,
}));
}
}, [checkIsAtBottom]);
return {
scrollRef,
isAtBottom: scrollState.isAtBottom,
autoScrollEnabled: scrollState.autoScrollEnabled,
scrollToBottom: () => scrollToBottom(false),
disableAutoScroll,
};
}
export const ScrollAreaWithAutoScroll = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
const { scrollRef } = useAutoScroll({
smooth: true,
content: children,
});
return (
<div
ref={scrollRef}
className={cn(
"flex grow flex-col items-center overflow-y-auto",
className,
)}
>
<div className="flex h-full w-full max-w-[97ch] flex-col pb-4">
{children}
</div>
</div>
);
};

View File

@ -73,6 +73,8 @@ const EnvironmentSchema = z.object({
//Trigger
TRIGGER_PROJECT_ID: z.string(),
TRIGGER_SECRET_KEY: z.string(),
TRIGGER_API_URL: z.string(),
// Model envs
MODEL: z.string().default(LLMModelEnum.GPT41),

View File

@ -31,7 +31,7 @@ import { usePostHog } from "./hooks/usePostHog";
import {
AppContainer,
MainCenteredContainer,
} from "./components/layout/AppLayout";
} from "./components/layout/app-layout";
import { RouteErrorDisplay } from "./components/ErrorDisplay";
import { themeSessionResolver } from "./services/sessionStorage.server";
import {

View File

@ -0,0 +1,21 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
const { action, loader } = createActionApiRoute(
{
body: IngestBodyRequest,
allowJWT: true,
authorization: {
action: "ingest",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const response = addToQueue(body, authentication.userId);
return json({ ...response });
},
);
export { action, loader };

View File

@ -22,6 +22,7 @@ const { action, loader } = createActionApiRoute(
action: "oauth",
},
corsStrategy: "all",
method: "POST",
},
async ({ authentication, params }) => {
const workspace = await getWorkspaceByUser(authentication.userId);

View File

@ -0,0 +1,41 @@
import { json } from "@remix-run/node";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
getConversationsList,
GetConversationsListSchema,
} from "~/services/conversation.server";
import { requireUser } from "~/services/session.server";
export const loader = async ({ request }: { request: Request }) => {
// Authenticate the request (allow JWT)
const user = await requireUser(request);
// Parse search params using the schema
const url = new URL(request.url);
const searchParamsObj: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
searchParamsObj[key] = value;
});
const parseResult = GetConversationsListSchema.safeParse(searchParamsObj);
if (!parseResult.success) {
return json(
{ error: "Invalid search parameters", details: parseResult.error.errors },
{ status: 400 },
);
}
const searchParams = parseResult.data;
const workspace = await getWorkspaceByUser(user.id);
if (!workspace) {
return json({ error: "No workspace found" }, { status: 404 });
}
const result = await getConversationsList(
workspace.id,
user.id,
searchParams || {},
);
return json(result);
};

View File

@ -0,0 +1,50 @@
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SearchService } from "~/services/search.server";
import { json } from "@remix-run/node";
export const SearchBodyRequest = z.object({
query: z.string(),
startTime: z.string().optional(),
endTime: z.string().optional(),
// These are not supported yet, but need to support these
spaceId: z.string().optional(),
limit: z.number().optional(),
maxBfsDepth: z.number().optional(),
includeInvalidated: z.boolean().optional(),
entityTypes: z.array(z.string()).optional(),
scoreThreshold: z.number().optional(),
minResults: z.number().optional(),
});
const searchService = new SearchService();
const { action, loader } = createActionApiRoute(
{
body: SearchBodyRequest,
allowJWT: true,
authorization: {
action: "search",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const results = await searchService.search(
body.query,
authentication.userId,
{
startTime: body.startTime ? new Date(body.startTime) : undefined,
endTime: body.endTime ? new Date(body.endTime) : undefined,
limit: body.limit,
maxBfsDepth: body.maxBfsDepth,
includeInvalidated: body.includeInvalidated,
entityTypes: body.entityTypes,
scoreThreshold: body.scoreThreshold,
minResults: body.minResults,
},
);
return json(results);
},
);
export { action, loader };

View File

@ -3,7 +3,7 @@ import { useActionData } from "@remix-run/react";
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { useForm } from "@conform-to/react";
import { getFieldsetConstraint, parse } from "@conform-to/zod";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { LoginPageLayout } from "~/components/layout/login-page-layout";
import {
Card,
CardContent,

View File

@ -0,0 +1,187 @@
import {
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/server-runtime";
import { sort } from "fast-sort";
import { useParams, useRevalidator } from "@remix-run/react";
import {
requireUser,
requireUserId,
requireWorkpace,
} from "~/services/session.server";
import {
getConversationAndHistory,
getCurrentConversationRun,
stopConversation,
} from "~/services/conversation.server";
import { type ConversationHistory } from "@core/database";
import {
ConversationItem,
ConversationList,
ConversationTextarea,
StreamingConversation,
} from "~/components/conversation";
import { useTypedLoaderData } from "remix-typedjson";
import React from "react";
import { ScrollAreaWithAutoScroll } from "~/components/use-auto-scroll";
import { json } from "@remix-run/node";
import { env } from "~/env.server";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
// Example loader accessing params
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const workspace = await requireWorkpace(request);
const conversation = await getConversationAndHistory(
params.conversationId as string,
user.id,
);
if (!conversation) {
throw new Error("No conversation found");
}
const run = await getCurrentConversationRun(conversation.id, workspace.id);
return { conversation, run, apiURL: env.TRIGGER_API_URL };
}
// Example action accessing params
export async function action({ params, request }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
// params.conversationId will be available here
const { conversationId } = params;
if (!conversationId) {
throw new Error("No conversation");
}
const result = await stopConversation(conversationId, workspace.id);
return json(result);
}
// Accessing params in the component
export default function SingleConversation() {
const { conversation, run, apiURL } = useTypedLoaderData<typeof loader>();
const conversationHistory = conversation.ConversationHistory;
const [conversationResponse, setConversationResponse] = React.useState<
{ conversationHistoryId: string; id: string; token: string } | undefined
>(run);
const [stopLoading, setStopLoading] = React.useState(false);
const { conversationId } = useParams();
const revalidator = useRevalidator();
React.useEffect(() => {
if (run) {
setConversationResponse(run);
}
}, [run]);
const getConversations = () => {
const lastConversationHistoryId =
conversationResponse?.conversationHistoryId;
// First sort the conversation history by creation time
const sortedConversationHistory = sort(conversationHistory).asc(
(ch) => ch.createdAt,
);
const lastIndex = sortedConversationHistory.findIndex(
(item) => item.id === lastConversationHistoryId,
);
// Filter out any conversation history items that come after the lastConversationHistoryId
const filteredConversationHistory = lastConversationHistoryId
? sortedConversationHistory.filter((_ch, currentIndex: number) => {
// Find the index of the last conversation history
// Only keep items that come before or are the last conversation history
return currentIndex <= lastIndex;
})
: sortedConversationHistory;
return (
<>
{filteredConversationHistory.map(
(ch: ConversationHistory, index: number) => {
return <ConversationItem key={index} conversationHistory={ch} />;
},
)}
</>
);
};
if (typeof window === "undefined") {
return null;
}
return (
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
<ResizablePanel
maxSize={50}
defaultSize={16}
minSize={16}
collapsible
collapsedSize={16}
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
>
<ConversationList currentConversationId={conversationId} />
</ResizablePanel>
<ResizableHandle className="w-1" />
<ResizablePanel
collapsible
collapsedSize={0}
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
>
<div className="relative flex h-[calc(100vh_-_70px)] w-full flex-col items-center justify-center overflow-auto">
<div className="flex h-[calc(100vh_-_60px)] w-full flex-col justify-end overflow-hidden">
<ScrollAreaWithAutoScroll>
{getConversations()}
{conversationResponse && (
<StreamingConversation
runId={conversationResponse.id}
token={conversationResponse.token}
afterStreaming={() => {
setConversationResponse(undefined);
revalidator.revalidate();
}}
apiURL={apiURL}
/>
)}
</ScrollAreaWithAutoScroll>
<div className="flex w-full flex-col items-center">
<div className="w-full max-w-[97ch] px-1 pr-2">
{conversation?.status !== "need_approval" && (
<ConversationTextarea
conversationId={conversationId as string}
className="bg-background-3 w-full border-1 border-gray-300"
isLoading={
!!conversationResponse ||
conversation?.status === "running" ||
stopLoading
}
/>
)}
</div>
</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View File

@ -0,0 +1,72 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import { useTypedLoaderData } from "remix-typedjson";
import { parse } from "@conform-to/zod";
import {
requireUser,
requireUserId,
requireWorkpace,
} from "~/services/session.server";
import { ConversationNew } from "~/components/conversation";
import {
createConversation,
CreateConversationSchema,
} from "~/services/conversation.server";
import { json } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
// Only return userId, not the heavy nodeLinks
const user = await requireUser(request);
return { user };
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const userId = await requireUserId(request);
const workspace = await requireWorkpace(request);
const formData = await request.formData();
const submission = parse(formData, { schema: CreateConversationSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
const conversation = await createConversation(workspace?.id, userId, {
message: submission.value.message,
title: submission.value.title,
conversationId: submission.value.conversationId,
});
// Redirect to the conversation page after creation
// conversationId may be in different places depending on createConversation logic
const conversationId = conversation?.conversationId;
if (conversationId) {
return new Response(null, {
status: 302,
headers: {
Location: `/home/conversation/${conversationId}`,
},
});
}
// fallback: just return the conversation object
return json({ conversation });
}
export default function Chat() {
const { user } = useTypedLoaderData<typeof loader>();
return (
<>{typeof window !== "undefined" && <ConversationNew user={user} />}</>
);
}

View File

@ -1,29 +1,19 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { parse } from "@conform-to/zod";
import { json } from "@remix-run/node";
import { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Ingest } from "~/components/dashboard/ingest";
import {
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
import { getNodeLinks } from "~/lib/neo4j.server";
import { useTypedLoaderData } from "remix-typedjson";
import { Search } from "~/components/dashboard";
import { SearchBodyRequest } from "./search";
import { SearchService } from "~/services/search.server";
import { GraphVisualizationClient } from "~/components/graph/graph-client";
// --- Only return userId in loader, fetch nodeLinks on client ---
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();

View File

@ -1,3 +1,6 @@
// DEPRECATED: This route is deprecated. Please use /api/v1/add instead.
// The API logic has been moved to /api/v1/add. This file is retained for reference only.
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";

View File

@ -1,7 +1,7 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { LoginPageLayout } from "~/components/layout/login-page-layout";
import { Fieldset } from "~/components/ui/Fieldset";
import { isGoogleAuthSupported } from "~/services/auth.server";
import { setRedirectTo } from "~/services/redirectTo.server";

View File

@ -16,7 +16,7 @@ import { Form, useNavigation } from "@remix-run/react";
import { Inbox, Loader, Mail } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { LoginPageLayout } from "~/components/layout/login-page-layout";
import { Button } from "~/components/ui";
import { Fieldset } from "~/components/ui/Fieldset";
import { FormButtons } from "~/components/ui/FormButtons";

View File

@ -1,3 +1,6 @@
// DEPRECATED: This route is deprecated. Please use /api/v1/search instead.
// The API logic has been moved to /api/v1/search. This file is retained for reference only.
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { SearchService } from "~/services/search.server";

View File

@ -1,6 +1,3 @@
import { SignJWT, errors, jwtVerify } from "jose";
import { env } from "~/env.server";
import { findUserByToken } from "~/models/personal-token.server";
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20

View File

@ -267,3 +267,95 @@ export async function getConversationContext(
previousHistory,
};
}
export const getConversationAndHistory = async (
conversationId: string,
userId: string,
) => {
const conversation = await prisma.conversation.findFirst({
where: {
id: conversationId,
},
include: {
ConversationHistory: true,
},
});
return conversation;
};
export const GetConversationsListSchema = z.object({
page: z.string().optional().default("1"),
limit: z.string().optional().default("20"),
search: z.string().optional(),
});
export type GetConversationsListDto = z.infer<typeof GetConversationsListSchema>;
export async function getConversationsList(
workspaceId: string,
userId: string,
params: GetConversationsListDto,
) {
const page = parseInt(params.page);
const limit = parseInt(params.limit);
const skip = (page - 1) * limit;
const where = {
workspaceId,
userId,
deleted: null,
...(params.search && {
OR: [
{
title: {
contains: params.search,
mode: "insensitive" as const,
},
},
{
ConversationHistory: {
some: {
message: {
contains: params.search,
mode: "insensitive" as const,
},
},
},
},
],
}),
};
const [conversations, total] = await Promise.all([
prisma.conversation.findMany({
where,
include: {
ConversationHistory: {
take: 1,
orderBy: {
createdAt: "desc",
},
},
},
orderBy: {
updatedAt: "desc",
},
skip,
take: limit,
}),
prisma.conversation.count({ where }),
]);
return {
conversations,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1,
},
};
}

View File

@ -62,7 +62,17 @@ export async function requireUser(request: Request) {
export async function requireWorkpace(request: Request) {
const userId = await requireUserId(request);
return getWorkspaceByUser(userId);
const workspace = await getWorkspaceByUser(userId);
if (!workspace) {
const url = new URL(request.url);
const searchParams = new URLSearchParams([
["redirectTo", `${url.pathname}${url.search}`],
]);
throw redirect(`/login?${searchParams}`);
}
return workspace;
}
export async function logout(request: Request) {

View File

@ -12,7 +12,7 @@
--foreground: oklch(0% 0 0);
--popover: oklch(93.05% 0 0);
--popover-foreground: oklch(0% 0 0);
--primary: oklch(54% 0.1789 271);
--primary: oklch(60% 0.13 30);
--primary-foreground: oklch(100% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(0% 0 0);
@ -49,7 +49,7 @@
--foreground: oklch(92.8% 0 0);
--popover: oklch(28.5% 0 0);
--popover-foreground: oklch(92.8% 0 0);
--primary: oklch(54% 0.1789 271);
--primary: oklch(60% 0.13 30);
--primary-foreground: oklch(92.8% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(92.8% 0 0);
@ -327,4 +327,44 @@
body {
@apply bg-background-2 text-foreground text-base;
}
}
@supports (scrollbar-width: auto) {
.overflow-y-auto,
.overflow-x-auto,
.overflow-auto {
overflow-anchor: none;
scrollbar-color: var(--gray-500) transparent;
scrollbar-width: thin;
}
}
/* Legacy browsers with `::-webkit-scrollbar-*` support */
@supports selector(::-webkit-scrollbar) {
.overflow-y-auto::-webkit-scrollbar-thumb,
.overflow-x-auto::-webkit-scrollbar-thumb,
.overflow-auto::-webkit-scrollbar-thumb {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-track,
.overflow-x-auto::-webkit-scrollbar-track,
.overflow-auto::-webkit-scrollbar-track {
background: var(--gray-600);
}
.overflow-y-auto::-webkit-scrollbar,
.overflow-x-auto::-webkit-scrollbar,
.overflow-auto::-webkit-scrollbar {
max-width: 5px;
}
}
nav[aria-label='breadcrumb'] li {
@apply text-base;
}
p.is-editor-empty {
font-size: 14px !important;
}
}

View File

@ -1,7 +1,5 @@
import { PrismaClient } from "@prisma/client";
import { ActionStatusEnum } from "@core/types";
import { logger, metadata, task } from "@trigger.dev/sdk/v3";
import { format } from "date-fns";
import { metadata, task, queue } from "@trigger.dev/sdk";
import { run } from "./chat-utils";
import { MCP } from "../utils/mcp";
@ -17,7 +15,10 @@ import {
updateExecutionStep,
} from "../utils/utils";
const prisma = new PrismaClient();
const chatQueue = queue({
name: "chat-queue",
concurrencyLimit: 10,
});
/**
* Main chat task that orchestrates the agent workflow
@ -26,10 +27,7 @@ const prisma = new PrismaClient();
export const chat = task({
id: "chat",
maxDuration: 3000,
queue: {
name: "chat",
concurrencyLimit: 30,
},
queue: chatQueue,
init,
run: async (payload: RunChatPayload, { init }) => {
await updateConversationStatus("running", payload.conversationId);
@ -39,8 +37,6 @@ export const chat = task({
const { previousHistory, ...otherData } = payload.context;
const isContinuation = payload.isContinuation || false;
// Initialise mcp
const mcp = new MCP();
await mcp.init();

View File

@ -144,7 +144,7 @@ export interface RunChatPayload {
isContinuation?: boolean;
}
export const init = async (payload: InitChatPayload) => {
export const init = async ({ payload }: { payload: InitChatPayload }) => {
logger.info("Loading init");
const conversationHistory = await prisma.conversationHistory.findUnique({
where: { id: payload.conversationHistoryId },

View File

@ -9,7 +9,7 @@
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc",
"trigger:dev": "npx trigger.dev@latest dev"
"trigger:dev": "pnpm dlx trigger.dev@v4-beta dev"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
@ -54,7 +54,16 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.1.7",
"@tanstack/react-table": "^8.13.2",
"@trigger.dev/sdk": "^3.3.17",
"@tiptap/extension-document": "^2.11.9",
"@tiptap/extension-hard-break": "^2.11.9",
"@tiptap/extension-history": "^2.11.9",
"@tiptap/extension-paragraph": "^2.11.9",
"@tiptap/extension-text": "^2.11.9",
"@tiptap/starter-kit": "2.11.9",
"@tiptap/react": "^2.11.9",
"@tiptap/pm": "^2.11.9",
"@trigger.dev/sdk": "^4.0.0-v4-beta.22",
"@trigger.dev/react-hooks": "^4.0.0-v4-beta.22",
"ai": "4.3.14",
"axios": "^1.10.0",
"bullmq": "^5.53.2",
@ -70,6 +79,7 @@
"emails": "workspace:*",
"execa": "^9.6.0",
"express": "^4.18.1",
"fast-sort": "^3.4.0",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"graphology-layout-forceatlas2": "^0.10.1",
@ -83,6 +93,7 @@
"nanoid": "3.3.8",
"neo4j-driver": "^5.28.1",
"non.geist": "^1.0.2",
"novel": "^1.0.2",
"ollama-ai-provider": "1.2.0",
"posthog-js": "^1.116.6",
"react": "^18.2.0",
@ -113,7 +124,7 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7",
"@trigger.dev/build": "^3.3.17",
"@trigger.dev/build": "^4.0.0-v4-beta.22",
"@types/compression": "^1.7.2",
"@types/d3": "^7.4.3",
"@types/express": "^4.17.13",
@ -121,6 +132,7 @@
"@types/simple-oauth2": "^5.0.7",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-virtualized": "^9.22.0",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",

View File

@ -0,0 +1,378 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
previewFeatures = ["tracing"]
}
model Activity {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
text String
// Used to link the task or activity to external apps
sourceURL String?
integrationAccount IntegrationAccount? @relation(fields: [integrationAccountId], references: [id])
integrationAccountId String?
rejectionReason String?
workspace Workspace @relation(fields: [workspaceId], references: [id])
workspaceId String
WebhookDeliveryLog WebhookDeliveryLog[]
ConversationHistory ConversationHistory[]
}
model AuthorizationCode {
id String @id @default(cuid())
code String @unique
personalAccessToken PersonalAccessToken? @relation(fields: [personalAccessTokenId], references: [id], onDelete: Cascade, onUpdate: Cascade)
personalAccessTokenId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Conversation {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
unread Boolean @default(false)
title String?
user User @relation(fields: [userId], references: [id])
userId String
workspace Workspace? @relation(fields: [workspaceId], references: [id])
workspaceId String?
status String @default("pending") // Can be "pending", "running", "completed", "failed", "need_attention"
ConversationHistory ConversationHistory[]
}
model ConversationExecutionStep {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
thought String
message String
actionId String?
actionOutput String?
actionInput String?
actionStatus String?
metadata Json? @default("{}")
conversationHistory ConversationHistory @relation(fields: [conversationHistoryId], references: [id])
conversationHistoryId String
}
model ConversationHistory {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
message String
userType UserType
activity Activity? @relation(fields: [activityId], references: [id])
activityId String?
context Json?
thoughts Json?
user User? @relation(fields: [userId], references: [id])
userId String?
conversation Conversation @relation(fields: [conversationId], references: [id])
conversationId String
ConversationExecutionStep ConversationExecutionStep[]
}
model Entity {
id String @id @default(cuid())
name String @unique // e.g., "User", "Issue", "Task", "Automation"
metadata Json // Store field definitions and their types
// Relations
spaceEntities SpaceEntity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model IngestionQueue {
id String @id @default(cuid())
// Relations
space Space? @relation(fields: [spaceId], references: [id])
spaceId String?
// Queue metadata
data Json // The actual data to be processed
output Json? // The processed output data
status IngestionStatus
priority Int @default(0)
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id])
// Error handling
error String?
retryCount Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
processedAt DateTime?
}
model IntegrationAccount {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
integrationConfiguration Json
accountId String?
settings Json?
isActive Boolean @default(true)
integratedBy User @relation(references: [id], fields: [integratedById])
integratedById String
integrationDefinition IntegrationDefinitionV2 @relation(references: [id], fields: [integrationDefinitionId])
integrationDefinitionId String
workspace Workspace @relation(references: [id], fields: [workspaceId])
workspaceId String
Activity Activity[]
@@unique([accountId, integrationDefinitionId, workspaceId])
}
model IntegrationDefinitionV2 {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
name String @unique
slug String
description String
icon String
config Json?
spec Json @default("{}")
version String?
url String?
workspace Workspace? @relation(references: [id], fields: [workspaceId])
workspaceId String?
IntegrationAccount IntegrationAccount[]
}
model InvitationCode {
id String @id @default(cuid())
code String @unique
users User[]
createdAt DateTime @default(now())
}
model PersonalAccessToken {
id String @id @default(cuid())
/// If generated by the CLI this will be "cli", otherwise user-provided
name String
/// This is the token encrypted using the ENCRYPTION_KEY
encryptedToken Json
/// This is shown in the UI, with ********
obfuscatedToken String
/// This is used to find the token in the database
hashedToken String @unique
user User @relation(fields: [userId], references: [id])
userId String
revokedAt DateTime?
lastAccessedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorizationCodes AuthorizationCode[]
}
model Space {
id String @id @default(cuid())
name String
description String?
autoMode Boolean @default(false)
// Relations
user User @relation(fields: [userId], references: [id])
userId String
// Space's enabled entities
enabledEntities SpaceEntity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
IngestionQueue IngestionQueue[]
}
model SpaceEntity {
id String @id @default(cuid())
// Relations
space Space @relation(fields: [spaceId], references: [id])
spaceId String
entity Entity @relation(fields: [entityId], references: [id])
entityId String
// Custom settings for this entity in this space
settings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, entityId])
}
model User {
id String @id @default(cuid())
email String @unique
authenticationMethod AuthenticationMethod
authenticationProfile Json?
authenticationExtraParams Json?
authIdentifier String? @unique
displayName String?
name String?
avatarUrl String?
memoryFilter String? // Adding memory filter instructions
admin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
marketingEmails Boolean @default(true)
confirmedBasicDetails Boolean @default(false)
referralSource String?
personalAccessTokens PersonalAccessToken[]
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
invitationCodeId String?
Space Space[]
Workspace Workspace?
IntegrationAccount IntegrationAccount[]
WebhookConfiguration WebhookConfiguration[]
Conversation Conversation[]
ConversationHistory ConversationHistory[]
}
model WebhookConfiguration {
id String @id @default(cuid())
url String
secret String?
isActive Boolean @default(true)
eventTypes String[] // List of event types this webhook is interested in, e.g. ["activity.created"]
user User? @relation(fields: [userId], references: [id])
userId String?
workspace Workspace? @relation(fields: [workspaceId], references: [id])
workspaceId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
WebhookDeliveryLog WebhookDeliveryLog[]
}
model WebhookDeliveryLog {
id String @id @default(cuid())
webhookConfiguration WebhookConfiguration @relation(fields: [webhookConfigurationId], references: [id])
webhookConfigurationId String
activity Activity? @relation(fields: [activityId], references: [id])
activityId String?
status WebhookDeliveryStatus
responseStatusCode Int?
responseBody String?
error String?
deliveredAt DateTime @default(now())
createdAt DateTime @default(now())
}
model Workspace {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deleted DateTime?
name String
slug String @unique
icon String?
integrations String[]
userId String? @unique
user User? @relation(fields: [userId], references: [id])
IngestionQueue IngestionQueue[]
IntegrationAccount IntegrationAccount[]
IntegrationDefinitionV2 IntegrationDefinitionV2[]
Activity Activity[]
WebhookConfiguration WebhookConfiguration[]
Conversation Conversation[]
}
enum AuthenticationMethod {
GOOGLE
MAGIC_LINK
}
enum IngestionStatus {
PENDING
PROCESSING
COMPLETED
FAILED
CANCELLED
}
enum UserType {
Agent
User
System
}
enum WebhookDeliveryStatus {
SUCCESS
FAILED
}

View File

@ -1,4 +1,9 @@
import { defineConfig } from "@trigger.dev/sdk/v3";
import {
additionalPackages,
syncEnvVars,
} from "@trigger.dev/build/extensions/core";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_ID as string,
@ -19,4 +24,15 @@ export default defineConfig({
},
},
dirs: ["./app/trigger"],
build: {
extensions: [
syncEnvVars(() => ({
DATABASE_URL: process.env.DATABASE_URL,
BACKEND_HOST: process.env.BACKEND_HOST,
})),
prismaExtension({
schema: "prisma/schema.prisma",
}),
],
},
});

View File

@ -28,7 +28,13 @@ export default defineConfig({
allowedHosts: true,
},
ssr: {
noExternal: ["@core/database", "tailwindcss"],
target: "node",
noExternal: [
"@core/database",
"tailwindcss",
"@tiptap/react",
"react-tweet",
],
external: ["@prisma/client"],
},
});

1767
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,8 @@
"dependsOn": [ "^generate" ]
},
"trigger:dev": {
"interactive": true,
"cache": false
}
},
"globalDependencies": [ ".env" ],
@ -66,6 +67,8 @@
"OLLAMA_URL",
"TRIGGER_PROJECT_ID",
"TRIGGER_API_URL",
"TRIGGER_API_KEY"
"TRIGGER_SECRET_KEY",
"EMBEDDING_MODEL",
"MODEL"
]
}