mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-25 10:08:27 +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 { type GraphVisualizationProps } from "./graph-visualization";
|
||||||
import { useState, useMemo, forwardRef, useRef, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function GraphVisualizationClient(props: GraphVisualizationProps) {
|
export function GraphVisualizationClient(props: GraphVisualizationProps) {
|
||||||
const [Component, setComponent] = useState<any>(undefined);
|
const [Component, setComponent] = useState<any>(undefined);
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import GraphologyGraph from "graphology";
|
|||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
import noverlap from "graphology-layout-noverlap";
|
import noverlap from "graphology-layout-noverlap";
|
||||||
import colors from "tailwindcss/colors";
|
import colors from "tailwindcss/colors";
|
||||||
import ForceSupervisor from "graphology-layout-force/worker";
|
|
||||||
import type { GraphTriplet, IdValue, GraphNode } from "./type";
|
import type { GraphTriplet, IdValue, GraphNode } from "./type";
|
||||||
import {
|
import {
|
||||||
createLabelColorMap,
|
createLabelColorMap,
|
||||||
@ -142,7 +141,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
nodeMap.set(triplet.source.id, {
|
nodeMap.set(triplet.source.id, {
|
||||||
id: triplet.source.id,
|
id: triplet.source.id,
|
||||||
label: triplet.source.value,
|
label: triplet.source.value,
|
||||||
size: 10,
|
size: 5,
|
||||||
color: getNodeColor(triplet.source),
|
color: getNodeColor(triplet.source),
|
||||||
x: width,
|
x: width,
|
||||||
y: height,
|
y: height,
|
||||||
@ -153,7 +152,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
nodeMap.set(triplet.target.id, {
|
nodeMap.set(triplet.target.id, {
|
||||||
id: triplet.target.id,
|
id: triplet.target.id,
|
||||||
label: triplet.target.value,
|
label: triplet.target.value,
|
||||||
size: 10,
|
size: 5,
|
||||||
color: getNodeColor(triplet.target),
|
color: getNodeColor(triplet.target),
|
||||||
x: width,
|
x: width,
|
||||||
y: height,
|
y: height,
|
||||||
@ -298,10 +297,10 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
graph.setNodeAttribute(node, "y", height);
|
graph.setNodeAttribute(node, "y", height);
|
||||||
});
|
});
|
||||||
|
|
||||||
const layout = new ForceSupervisor(graph, {
|
// const layout = new ForceSupervisor(graph, {
|
||||||
isNodeFixed: (_, attr) => attr.highlighted,
|
// isNodeFixed: (_, attr) => attr.highlighted,
|
||||||
});
|
// });
|
||||||
layout.start();
|
// layout.start();
|
||||||
|
|
||||||
const settings = forceAtlas2.inferSettings(graph);
|
const settings = forceAtlas2.inferSettings(graph);
|
||||||
forceAtlas2.assign(graph, {
|
forceAtlas2.assign(graph, {
|
||||||
@ -309,7 +308,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
settings: {
|
settings: {
|
||||||
...settings,
|
...settings,
|
||||||
barnesHutOptimize: true,
|
barnesHutOptimize: true,
|
||||||
strongGravityMode: true,
|
strongGravityMode: false,
|
||||||
gravity: 0.05,
|
gravity: 0.05,
|
||||||
scalingRatio: 10,
|
scalingRatio: 10,
|
||||||
slowDown: 5,
|
slowDown: 5,
|
||||||
@ -332,7 +331,7 @@ export const Graph = forwardRef<GraphRef, GraphProps>(
|
|||||||
defaultEdgeColor: theme.link.stroke,
|
defaultEdgeColor: theme.link.stroke,
|
||||||
defaultNodeColor: theme.node.fill,
|
defaultNodeColor: theme.node.fill,
|
||||||
enableEdgeEvents: true,
|
enableEdgeEvents: true,
|
||||||
minCameraRatio: 0.5,
|
minCameraRatio: 0.1,
|
||||||
maxCameraRatio: 2,
|
maxCameraRatio: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,15 +3,13 @@ import Logo from "../logo/logo";
|
|||||||
import { Theme, useTheme } from "remix-themes";
|
import { Theme, useTheme } from "remix-themes";
|
||||||
|
|
||||||
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||||
const [, setTheme] = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
<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">
|
<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>
|
<div className="font-mono">C.O.R.E.</div>
|
||||||
</a>
|
</a>
|
||||||
{children}
|
{children}
|
||||||
@ -17,8 +17,8 @@ import Logo from "../logo/logo";
|
|||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Chat",
|
title: "Conversation",
|
||||||
url: "/home/chat",
|
url: "/home/conversation",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useLocation } from "@remix-run/react";
|
|||||||
|
|
||||||
const PAGE_TITLES: Record<string, string> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
"/home/dashboard": "Memory graph",
|
"/home/dashboard": "Memory graph",
|
||||||
"/home/chat": "Chat",
|
"/home/conversation": "Conversation",
|
||||||
"/home/integrations": "Integrations",
|
"/home/integrations": "Integrations",
|
||||||
"/home/activity": "Activity",
|
"/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
|
||||||
TRIGGER_PROJECT_ID: z.string(),
|
TRIGGER_PROJECT_ID: z.string(),
|
||||||
|
TRIGGER_SECRET_KEY: z.string(),
|
||||||
|
TRIGGER_API_URL: z.string(),
|
||||||
|
|
||||||
// Model envs
|
// Model envs
|
||||||
MODEL: z.string().default(LLMModelEnum.GPT41),
|
MODEL: z.string().default(LLMModelEnum.GPT41),
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import { usePostHog } from "./hooks/usePostHog";
|
|||||||
import {
|
import {
|
||||||
AppContainer,
|
AppContainer,
|
||||||
MainCenteredContainer,
|
MainCenteredContainer,
|
||||||
} from "./components/layout/AppLayout";
|
} from "./components/layout/app-layout";
|
||||||
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
import { RouteErrorDisplay } from "./components/ErrorDisplay";
|
||||||
import { themeSessionResolver } from "./services/sessionStorage.server";
|
import { themeSessionResolver } from "./services/sessionStorage.server";
|
||||||
import {
|
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",
|
action: "oauth",
|
||||||
},
|
},
|
||||||
corsStrategy: "all",
|
corsStrategy: "all",
|
||||||
|
method: "POST",
|
||||||
},
|
},
|
||||||
async ({ authentication, params }) => {
|
async ({ authentication, params }) => {
|
||||||
const workspace = await getWorkspaceByUser(authentication.userId);
|
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 { type ActionFunctionArgs, json } from "@remix-run/node";
|
||||||
import { useForm } from "@conform-to/react";
|
import { useForm } from "@conform-to/react";
|
||||||
import { getFieldsetConstraint, parse } from "@conform-to/zod";
|
import { getFieldsetConstraint, parse } from "@conform-to/zod";
|
||||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
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 { parse } from "@conform-to/zod";
|
||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
||||||
import { Ingest } from "~/components/dashboard/ingest";
|
|
||||||
import {
|
import {
|
||||||
type LoaderFunctionArgs,
|
type LoaderFunctionArgs,
|
||||||
type ActionFunctionArgs,
|
type ActionFunctionArgs,
|
||||||
} from "@remix-run/server-runtime";
|
} from "@remix-run/server-runtime";
|
||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
|
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
|
||||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
|
||||||
import { useTypedLoaderData } from "remix-typedjson";
|
import { useTypedLoaderData } from "remix-typedjson";
|
||||||
|
|
||||||
import { Search } from "~/components/dashboard";
|
|
||||||
import { SearchBodyRequest } from "./search";
|
import { SearchBodyRequest } from "./search";
|
||||||
import { SearchService } from "~/services/search.server";
|
import { SearchService } from "~/services/search.server";
|
||||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||||
|
|
||||||
// --- Only return userId in loader, fetch nodeLinks on client ---
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
const formData = await request.formData();
|
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 { json } from "@remix-run/node";
|
||||||
|
|
||||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
|
||||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
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 { Fieldset } from "~/components/ui/Fieldset";
|
||||||
import { isGoogleAuthSupported } from "~/services/auth.server";
|
import { isGoogleAuthSupported } from "~/services/auth.server";
|
||||||
import { setRedirectTo } from "~/services/redirectTo.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 { Inbox, Loader, Mail } from "lucide-react";
|
||||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||||
import { Button } from "~/components/ui";
|
import { Button } from "~/components/ui";
|
||||||
import { Fieldset } from "~/components/ui/Fieldset";
|
import { Fieldset } from "~/components/ui/Fieldset";
|
||||||
import { FormButtons } from "~/components/ui/FormButtons";
|
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 { z } from "zod";
|
||||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||||
import { SearchService } from "~/services/search.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";
|
import { findUserByToken } from "~/models/personal-token.server";
|
||||||
|
|
||||||
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
|
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
|
||||||
|
|||||||
@ -267,3 +267,95 @@ export async function getConversationContext(
|
|||||||
previousHistory,
|
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) {
|
export async function requireWorkpace(request: Request) {
|
||||||
const userId = await requireUserId(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) {
|
export async function logout(request: Request) {
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
--foreground: oklch(0% 0 0);
|
--foreground: oklch(0% 0 0);
|
||||||
--popover: oklch(93.05% 0 0);
|
--popover: oklch(93.05% 0 0);
|
||||||
--popover-foreground: oklch(0% 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);
|
--primary-foreground: oklch(100% 0 0);
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: oklch(0% 0 0);
|
--secondary-foreground: oklch(0% 0 0);
|
||||||
@ -49,7 +49,7 @@
|
|||||||
--foreground: oklch(92.8% 0 0);
|
--foreground: oklch(92.8% 0 0);
|
||||||
--popover: oklch(28.5% 0 0);
|
--popover: oklch(28.5% 0 0);
|
||||||
--popover-foreground: oklch(92.8% 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);
|
--primary-foreground: oklch(92.8% 0 0);
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: oklch(92.8% 0 0);
|
--secondary-foreground: oklch(92.8% 0 0);
|
||||||
@ -327,4 +327,44 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background-2 text-foreground text-base;
|
@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 { ActionStatusEnum } from "@core/types";
|
||||||
import { logger, metadata, task } from "@trigger.dev/sdk/v3";
|
import { metadata, task, queue } from "@trigger.dev/sdk";
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
import { run } from "./chat-utils";
|
import { run } from "./chat-utils";
|
||||||
import { MCP } from "../utils/mcp";
|
import { MCP } from "../utils/mcp";
|
||||||
@ -17,7 +15,10 @@ import {
|
|||||||
updateExecutionStep,
|
updateExecutionStep,
|
||||||
} from "../utils/utils";
|
} from "../utils/utils";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const chatQueue = queue({
|
||||||
|
name: "chat-queue",
|
||||||
|
concurrencyLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main chat task that orchestrates the agent workflow
|
* Main chat task that orchestrates the agent workflow
|
||||||
@ -26,10 +27,7 @@ const prisma = new PrismaClient();
|
|||||||
export const chat = task({
|
export const chat = task({
|
||||||
id: "chat",
|
id: "chat",
|
||||||
maxDuration: 3000,
|
maxDuration: 3000,
|
||||||
queue: {
|
queue: chatQueue,
|
||||||
name: "chat",
|
|
||||||
concurrencyLimit: 30,
|
|
||||||
},
|
|
||||||
init,
|
init,
|
||||||
run: async (payload: RunChatPayload, { init }) => {
|
run: async (payload: RunChatPayload, { init }) => {
|
||||||
await updateConversationStatus("running", payload.conversationId);
|
await updateConversationStatus("running", payload.conversationId);
|
||||||
@ -39,8 +37,6 @@ export const chat = task({
|
|||||||
|
|
||||||
const { previousHistory, ...otherData } = payload.context;
|
const { previousHistory, ...otherData } = payload.context;
|
||||||
|
|
||||||
const isContinuation = payload.isContinuation || false;
|
|
||||||
|
|
||||||
// Initialise mcp
|
// Initialise mcp
|
||||||
const mcp = new MCP();
|
const mcp = new MCP();
|
||||||
await mcp.init();
|
await mcp.init();
|
||||||
|
|||||||
@ -144,7 +144,7 @@ export interface RunChatPayload {
|
|||||||
isContinuation?: boolean;
|
isContinuation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const init = async (payload: InitChatPayload) => {
|
export const init = async ({ payload }: { payload: InitChatPayload }) => {
|
||||||
logger.info("Loading init");
|
logger.info("Loading init");
|
||||||
const conversationHistory = await prisma.conversationHistory.findUnique({
|
const conversationHistory = await prisma.conversationHistory.findUnique({
|
||||||
where: { id: payload.conversationHistoryId },
|
where: { id: payload.conversationHistoryId },
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
"start": "remix-serve ./build/server/index.js",
|
"start": "remix-serve ./build/server/index.js",
|
||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"trigger:dev": "npx trigger.dev@latest dev"
|
"trigger:dev": "pnpm dlx trigger.dev@v4-beta dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^1.2.12",
|
"@ai-sdk/anthropic": "^1.2.12",
|
||||||
@ -54,7 +54,16 @@
|
|||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@tanstack/react-table": "^8.13.2",
|
"@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",
|
"ai": "4.3.14",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"bullmq": "^5.53.2",
|
"bullmq": "^5.53.2",
|
||||||
@ -70,6 +79,7 @@
|
|||||||
"emails": "workspace:*",
|
"emails": "workspace:*",
|
||||||
"execa": "^9.6.0",
|
"execa": "^9.6.0",
|
||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
|
"fast-sort": "^3.4.0",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
"graphology-layout-force": "^0.2.4",
|
"graphology-layout-force": "^0.2.4",
|
||||||
"graphology-layout-forceatlas2": "^0.10.1",
|
"graphology-layout-forceatlas2": "^0.10.1",
|
||||||
@ -83,6 +93,7 @@
|
|||||||
"nanoid": "3.3.8",
|
"nanoid": "3.3.8",
|
||||||
"neo4j-driver": "^5.28.1",
|
"neo4j-driver": "^5.28.1",
|
||||||
"non.geist": "^1.0.2",
|
"non.geist": "^1.0.2",
|
||||||
|
"novel": "^1.0.2",
|
||||||
"ollama-ai-provider": "1.2.0",
|
"ollama-ai-provider": "1.2.0",
|
||||||
"posthog-js": "^1.116.6",
|
"posthog-js": "^1.116.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -113,7 +124,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@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/compression": "^1.7.2",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
@ -121,6 +132,7 @@
|
|||||||
"@types/simple-oauth2": "^5.0.7",
|
"@types/simple-oauth2": "^5.0.7",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@types/react-virtualized": "^9.22.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.7.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"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 { 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({
|
export default defineConfig({
|
||||||
project: process.env.TRIGGER_PROJECT_ID as string,
|
project: process.env.TRIGGER_PROJECT_ID as string,
|
||||||
@ -19,4 +24,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dirs: ["./app/trigger"],
|
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,
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ["@core/database", "tailwindcss"],
|
target: "node",
|
||||||
|
noExternal: [
|
||||||
|
"@core/database",
|
||||||
|
"tailwindcss",
|
||||||
|
"@tiptap/react",
|
||||||
|
"react-tweet",
|
||||||
|
],
|
||||||
external: ["@prisma/client"],
|
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" ]
|
"dependsOn": [ "^generate" ]
|
||||||
},
|
},
|
||||||
"trigger:dev": {
|
"trigger:dev": {
|
||||||
|
"interactive": true,
|
||||||
|
"cache": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": [ ".env" ],
|
"globalDependencies": [ ".env" ],
|
||||||
@ -66,6 +67,8 @@
|
|||||||
"OLLAMA_URL",
|
"OLLAMA_URL",
|
||||||
"TRIGGER_PROJECT_ID",
|
"TRIGGER_PROJECT_ID",
|
||||||
"TRIGGER_API_URL",
|
"TRIGGER_API_URL",
|
||||||
"TRIGGER_API_KEY"
|
"TRIGGER_SECRET_KEY",
|
||||||
|
"EMBEDDING_MODEL",
|
||||||
|
"MODEL"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user