mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-11 16:58:28 +00:00
Feat: added conversation
This commit is contained in:
parent
6ef523520d
commit
0767268770
1
apps/logs/errors/llm-error-2025-07-08T14-35-41.438Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-35-41.438Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-35-57.972Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-35-57.972Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-36-35.479Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-36-35.479Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-37-12.989Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-37-12.989Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-37-50.503Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-37-50.503Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-38-28.011Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-38-28.011Z.json
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
);
|
||||
};
|
||||
245
apps/webapp/app/components/conversation/conversation-list.tsx
Normal file
245
apps/webapp/app/components/conversation/conversation-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
151
apps/webapp/app/components/conversation/conversation.client.tsx
Normal file
151
apps/webapp/app/components/conversation/conversation.client.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
apps/webapp/app/components/conversation/index.ts
Normal file
5
apps/webapp/app/components/conversation/index.ts
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
103
apps/webapp/app/components/conversation/use-trigger-stream.tsx
Normal file
103
apps/webapp/app/components/conversation/use-trigger-stream.tsx
Normal 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 };
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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}
|
||||
@ -17,8 +17,8 @@ import Logo from "../logo/logo";
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Chat",
|
||||
url: "/home/chat",
|
||||
title: "Conversation",
|
||||
url: "/home/conversation",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
169
apps/webapp/app/components/use-auto-scroll.tsx
Normal file
169
apps/webapp/app/components/use-auto-scroll.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
21
apps/webapp/app/routes/api.v1.add.tsx
Normal file
21
apps/webapp/app/routes/api.v1.add.tsx
Normal 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 };
|
||||
@ -22,6 +22,7 @@ const { action, loader } = createActionApiRoute(
|
||||
action: "oauth",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
method: "POST",
|
||||
},
|
||||
async ({ authentication, params }) => {
|
||||
const workspace = await getWorkspaceByUser(authentication.userId);
|
||||
|
||||
41
apps/webapp/app/routes/api.v1.conversations.tsx
Normal file
41
apps/webapp/app/routes/api.v1.conversations.tsx
Normal 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);
|
||||
};
|
||||
50
apps/webapp/app/routes/api.v1.search.tsx
Normal file
50
apps/webapp/app/routes/api.v1.search.tsx
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
187
apps/webapp/app/routes/home.conversation.$conversationId.tsx
Normal file
187
apps/webapp/app/routes/home.conversation.$conversationId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/webapp/app/routes/home.conversation._index.tsx
Normal file
72
apps/webapp/app/routes/home.conversation._index.tsx
Normal 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} />}</>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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",
|
||||
|
||||
378
apps/webapp/prisma/schema.prisma
Normal file
378
apps/webapp/prisma/schema.prisma
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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
1767
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user