Feat: v2 (#12)

* Feat: v2

* feat: add chat functionality

* First cut: integrations

* Feat: add conversation API

* Enhance conversation handling and memory management

* Feat: added conversation

---------

Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
Harshith Mullapudi 2025-07-08 22:41:00 +05:30 committed by GitHub
parent a819a682a2
commit 54e535d57d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 17371 additions and 1499 deletions

View File

@ -6,7 +6,7 @@ POSTGRES_PASSWORD=docker
POSTGRES_DB=core
LOGIN_ORIGIN=http://localhost:3000
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=echo"
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=core"
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances
# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No
@ -41,5 +41,5 @@ MAGIC_LINK_SECRET=27192e6432564f4788d55c15131bd5ac
NEO4J_AUTH=neo4j/27192e6432564f4788d55c15131bd5ac
OLLAMA_URL=http://ollama:11434
EMBEDDING_MODEL=bge-m3
EMBEDDING_MODEL=GPT41
MODEL=GPT41

9
.gitignore vendored
View File

@ -37,4 +37,11 @@ yarn-error.log*
.DS_Store
*.pem
docker-compose.dev.yaml
docker-compose.dev.yaml
clickhouse/
.vscode/
registry/
.cursor
CLAUDE.md

57
LICENSE
View File

@ -1,21 +1,44 @@
MIT License
Sol License
Copyright (c) 2024 Poozle Inc
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Copyright (c) 2025 — Poozle Inc.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Additional Terms:
As additional permission under GNU AGPL version 3 section 7, you
may combine or link a "work that uses the Library" with a publicly
distributed version of this library to produce a combined library or
application, then distribute that combined work under the terms of
your choice, with no requirement to comply with the obligations
normally placed on you by section 4 of the GNU AGPL version 3
(or the corresponding section of a later version of the GNU AGPL
version 3 license).
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License (defined below), subject to the following condition:
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of the rights granted to you under the License to provide the Software to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/support services related to the Software), as part of a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
Software: All files in this repository.
License: GNU Affero General Public License v3.0
Licensor: Poozle Inc.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,3 +3,5 @@ node_modules
/.cache
/build
.env
.trigger

View File

@ -48,7 +48,7 @@ type DisplayOptionsProps = {
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
return (
<div className="bg-background relative flex min-h-screen flex-col items-center justify-center">
<div className="bg-background-2 relative flex min-h-screen flex-col items-center justify-center">
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
<Header1>{title}</Header1>
{message && <Paragraph>{message}</Paragraph>}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
import { type GraphVisualizationProps } from "./graph-visualization";
import { useState, useEffect } from "react";
export function GraphVisualizationClient(props: GraphVisualizationProps) {
const [Component, setComponent] = useState<any>(undefined);
useEffect(() => {
if (typeof window === "undefined") return;
import("./graph-visualization").then(({ GraphVisualization }) => {
setComponent(GraphVisualization);
});
}, []);
if (!Component) {
return null;
}
return <Component {...props} />;
}

View File

@ -93,7 +93,7 @@ export function GraphPopovers({
<div className="pointer-events-none h-4 w-4" />
</PopoverTrigger>
<PopoverContent
className="w-80 overflow-hidden"
className="h-60 max-w-80 overflow-auto"
side="bottom"
align="end"
sideOffset={5}

View File

@ -1,4 +1,4 @@
import { useState, useMemo, forwardRef } from "react";
import { useState, useMemo, forwardRef, useRef, useEffect } from "react";
import { Graph, type GraphRef } from "./graph";
import { GraphPopovers } from "./graph-popover";
import type { RawTriplet, NodePopupContent, EdgePopupContent } from "./type";
@ -7,7 +7,7 @@ import { createLabelColorMap } from "./node-colors";
import { toGraphTriplets } from "./utils";
interface GraphVisualizationProps {
export interface GraphVisualizationProps {
triplets: RawTriplet[];
width?: number;
height?: number;

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,34 @@
import colors from "tailwindcss/colors";
// Define a color palette for node coloring
// Define a color palette for node coloring using hex values directly
export const nodeColorPalette = {
light: [
"var(--custom-color-1)", // Entity (default)
"var(--custom-color-2)",
"var(--custom-color-3)",
"var(--custom-color-4)",
"var(--custom-color-5)",
"var(--custom-color-6)",
"var(--custom-color-7)",
"var(--custom-color-8)",
"var(--custom-color-9)",
"var(--custom-color-10)",
"var(--custom-color-11)",
"var(--custom-color-12)",
"#b56455", // Entity (default)
"#7b8a34",
"#1c91a8",
"#886dbc",
"#ad6e30",
"#54935b",
"#4187c0",
"#a165a1",
"#997d1d",
"#2b9684",
"#2b9684",
"#b0617c",
],
dark: [
"var(--custom-color-1)", // Entity (default)
"var(--custom-color-2)",
"var(--custom-color-3)",
"var(--custom-color-4)",
"var(--custom-color-5)",
"var(--custom-color-6)",
"var(--custom-color-7)",
"var(--custom-color-8)",
"var(--custom-color-9)",
"var(--custom-color-10)",
"var(--custom-color-11)",
"var(--custom-color-12)",
"#b56455", // Entity (default)
"#7b8a34",
"#1c91a8",
"#886dbc",
"#ad6e30",
"#54935b",
"#4187c0",
"#a165a1",
"#997d1d",
"#2b9684",
"#2b9684",
"#b0617c",
],
};

View File

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

View File

@ -0,0 +1,42 @@
<svg width="282" height="282" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80.0827 36.8297C92.7421 5.92292 120.776 19.7406 134.464 31.4565C135.321 32.19 135.792 33.2698 135.792 34.3978V250.904C135.792 252.083 135.253 253.224 134.336 253.966C103.335 279.044 85.2828 259.211 80.0827 245.933C44.9187 241.31 43.965 210.382 47.8837 195.496C15.173 188.351 17.5591 153.64 22.841 137.178C9.34813 109.018 33.9141 91.8201 47.8837 86.7414C40.524 52.2761 66.2831 39.1064 80.0827 36.8297Z" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="94.9384" y2="-2" transform="matrix(0.594988 0.803734 -0.785925 0.618321 77.3574 39.0923)" stroke="#C15E50" stroke-width="4"/>
<path d="M49.1309 86.2527L136.212 177.224" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="90.5781" y2="-2" transform="matrix(0.81717 0.576396 -0.552987 0.83319 32.5566 144.514)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="145.522" y2="-2" transform="matrix(0.689338 -0.72444 0.703134 0.711057 35.4785 140.498)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="77.0207" y2="-2" transform="matrix(0.531085 -0.847319 0.832259 0.554387 49.1133 196.723)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="111.293" y2="-2" transform="matrix(-0.980107 0.198471 -0.187173 -0.982327 135.791 118.41)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="58.2375" y2="-2" transform="matrix(0.535143 -0.844762 0.829524 0.558472 81.252 246.924)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="64.1562" y2="-2" transform="matrix(-0.506896 -0.862007 0.848017 -0.529968 137.443 252.989)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="45.3484" y2="-2" transform="matrix(-0.0859054 0.996303 -0.995828 -0.0912537 110.471 151.542)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="87.7438" y2="-2" transform="matrix(0.998952 -0.04577 0.0430721 0.999072 49.1133 198.731)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="64.1538" y2="-2" transform="matrix(-0.166991 0.985958 -0.984183 -0.177152 100.73 68.2088)" stroke="#C15E50" stroke-width="4"/>
<circle cx="102.68" cy="67.2048" r="9.88852" fill="#C15E50"/>
<ellipse cx="91.965" cy="129.454" rx="10.7131" ry="11.0442" fill="#C15E50"/>
<circle cx="106.574" cy="194.715" r="9.88852" fill="#C15E50"/>
<ellipse cx="49.5993" cy="86.7831" rx="7.30438" ry="7.53012" fill="#C15E50"/>
<ellipse cx="81.7387" cy="38.5903" rx="6.33046" ry="6.5261" fill="#C15E50"/>
<ellipse cx="27.2" cy="141" rx="11.2" ry="11.5462" fill="#C15E50"/>
<circle cx="81.2534" cy="243.912" r="5.93311" fill="#C15E50"/>
<circle cx="52.0352" cy="194.715" r="6.92197" fill="#C15E50"/>
<path d="M201.917 245.17C189.258 276.077 161.224 262.259 147.536 250.543C146.679 249.81 146.208 248.73 146.208 247.602V31.096C146.208 29.9172 146.747 28.7757 147.664 28.0343C178.665 2.95557 196.717 22.7885 201.917 36.0669C237.081 40.6903 238.035 71.618 234.116 86.5039C266.827 93.6492 264.441 128.36 259.159 144.822C272.652 172.982 248.086 190.18 234.116 195.259C241.476 229.724 215.717 242.894 201.917 245.17Z" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="94.9384" y2="-2" transform="matrix(-0.594988 -0.803734 0.785925 -0.618321 204.643 242.908)" stroke="#C15E50" stroke-width="4"/>
<path d="M232.869 195.747L145.788 104.776" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="90.5781" y2="-2" transform="matrix(-0.81717 -0.576396 0.552987 -0.83319 249.443 137.486)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="145.522" y2="-2" transform="matrix(-0.689338 0.72444 -0.703134 -0.711057 246.521 141.502)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="77.0207" y2="-2" transform="matrix(-0.531085 0.847319 -0.832259 -0.554387 232.887 85.2771)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="111.293" y2="-2" transform="matrix(0.980107 -0.198471 0.187173 0.982327 146.209 163.59)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="58.2375" y2="-2" transform="matrix(-0.535143 0.844762 -0.829524 -0.558472 200.748 35.0763)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="64.1562" y2="-2" transform="matrix(0.506896 0.862007 -0.848017 0.529968 144.557 29.0108)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="45.3484" y2="-2" transform="matrix(0.0859054 -0.996303 0.995828 0.0912537 171.529 130.458)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="87.7438" y2="-2" transform="matrix(-0.998952 0.04577 -0.0430721 -0.999072 232.887 83.2691)" stroke="#C15E50" stroke-width="4"/>
<line y1="-2" x2="64.1538" y2="-2" transform="matrix(0.166991 -0.985958 0.984183 0.177152 181.27 213.791)" stroke="#C15E50" stroke-width="4"/>
<circle cx="179.32" cy="214.795" r="9.88852" transform="rotate(180 179.32 214.795)" fill="#C15E50"/>
<ellipse cx="190.035" cy="152.546" rx="10.7131" ry="11.0442" transform="rotate(180 190.035 152.546)" fill="#C15E50"/>
<circle cx="175.426" cy="87.2852" r="9.88852" transform="rotate(180 175.426 87.2852)" fill="#C15E50"/>
<ellipse cx="232.401" cy="195.217" rx="7.30438" ry="7.53012" transform="rotate(180 232.401 195.217)" fill="#C15E50"/>
<ellipse cx="200.261" cy="243.41" rx="6.33046" ry="6.5261" transform="rotate(180 200.261 243.41)" fill="#C15E50"/>
<ellipse cx="254.8" cy="141" rx="11.2" ry="11.5462" transform="rotate(180 254.8 141)" fill="#C15E50"/>
<circle cx="200.747" cy="38.0884" r="5.93311" transform="rotate(180 200.747 38.0884)" fill="#C15E50"/>
<circle cx="229.965" cy="87.2852" r="6.92197" transform="rotate(180 229.965 87.2852)" fill="#C15E50"/>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -8,43 +8,52 @@ import {
SidebarMenu,
SidebarMenuItem,
} from "../ui/sidebar";
import { DashboardIcon } from "@radix-ui/react-icons";
import { Code, Search } from "lucide-react";
import { Activity, LayoutGrid, MessageSquare, Network } from "lucide-react";
import { NavMain } from "./nav-main";
import { useUser } from "~/hooks/useUser";
import { NavUser } from "./nav-user";
import { useWorkspace } from "~/hooks/useWorkspace";
import Logo from "../logo/logo";
const data = {
navMain: [
{
title: "Dashboard",
title: "Conversation",
url: "/home/conversation",
icon: MessageSquare,
},
{
title: "Memory",
url: "/home/dashboard",
icon: DashboardIcon,
icon: Network,
},
{
title: "API",
url: "/home/api",
icon: Code,
title: "Activity",
url: "/home/activity",
icon: Activity,
},
{
title: "Logs",
url: "/home/logs",
icon: Search,
title: "Integrations",
url: "/home/integrations",
icon: LayoutGrid,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const user = useUser();
const workspace = useWorkspace();
return (
<Sidebar collapsible="offcanvas" {...props} className="bg-background">
<Sidebar
collapsible="none"
{...props}
className="bg-background h-[100vh] w-[calc(var(--sidebar-width-icon)+1px)]! py-2"
>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<span className="text-base font-semibold">{workspace.name}</span>
<div className="mt-1 flex w-full items-center justify-center">
<Logo width={20} height={20} />
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>

View File

@ -1,3 +1,4 @@
import { cn } from "~/lib/utils";
import {
SidebarGroup,
SidebarGroupContent,
@ -28,10 +29,13 @@ export const NavMain = ({
<SidebarMenuButton
tooltip={item.title}
isActive={location.pathname.includes(item.url)}
className={cn(
location.pathname.includes(item.url) &&
"!bg-grayAlpha-100 hover:bg-grayAlpha-100!",
)}
onClick={() => navigate(item.url)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}

View File

@ -1,4 +1,4 @@
import { LogOut } from "lucide-react";
import { LogOut, Settings } from "lucide-react";
import { AvatarText } from "../ui/avatar";
import {
DropdownMenu,
@ -8,33 +8,48 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { SidebarMenu, SidebarMenuItem, useSidebar } from "../ui/sidebar";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "../ui/sidebar";
import type { User } from "~/models/user.server";
import { Button } from "../ui";
import { cn } from "~/lib/utils";
import { useLocation, useNavigate } from "@remix-run/react";
export function NavUser({ user }: { user: User }) {
const { isMobile } = useSidebar();
const location = useLocation();
const navigate = useNavigate();
return (
<SidebarMenu>
<SidebarMenuItem className="mb-2 flex justify-center">
<Button
variant="ghost"
isActive={location.pathname.includes("settings")}
className={cn(
location.pathname.includes("settings") &&
"!bg-grayAlpha-100 hover:bg-grayAlpha-100!",
)}
onClick={() => navigate("/settings")}
>
<Settings size={18} />
</Button>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="lg"
variant="link"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground ,b-2 mb-2 gap-2 px-2"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground mb-2 gap-2 px-3"
>
<AvatarText
text={user.name ?? "User"}
className="h-6 w-6 rounded"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -0,0 +1,32 @@
import { useLocation } from "@remix-run/react";
const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
"/home/conversation": "Conversation",
"/home/integrations": "Integrations",
"/home/activity": "Activity",
};
function getHeaderTitle(pathname: string): string {
// Try to match the most specific path first
for (const key of Object.keys(PAGE_TITLES)) {
if (pathname.startsWith(key)) {
return PAGE_TITLES[key];
}
}
// Default fallback
return "Documents";
}
export function SiteHeader() {
const location = useLocation();
const title = getHeaderTitle(location.pathname);
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2">
<h1 className="text-base">{title}</h1>
</div>
</header>
);
}

View File

@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 gap-4 bg-background-2 p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {

View File

@ -263,8 +263,7 @@ function SidebarTrigger({
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
className={cn("size-8", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
@ -307,7 +306,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"bg-background-3 relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
@ -324,7 +323,7 @@ function SidebarInput({
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
className={cn("bg-background-2 h-8 w-full shadow-none", className)}
{...props}
/>
);
@ -472,11 +471,11 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:!bg-background-3 active:!text-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: "hover:bg-grayAlpha-100 hover:text-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},

View File

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

View File

@ -71,6 +71,11 @@ const EnvironmentSchema = z.object({
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
//Trigger
TRIGGER_PROJECT_ID: z.string(),
TRIGGER_SECRET_KEY: z.string(),
TRIGGER_API_URL: z.string(),
// Model envs
MODEL: z.string().default(LLMModelEnum.GPT41),
EMBEDDING_MODEL: z.string().default("bge-m3"),

View File

@ -2,7 +2,6 @@ import { type Workspace } from "@core/database";
import { prisma } from "~/db.server";
interface CreateWorkspaceDto {
slug: string;
name: string;
integrations: string[];
userId: string;
@ -13,7 +12,7 @@ export async function createWorkspace(
): Promise<Workspace> {
const workspace = await prisma.workspace.create({
data: {
slug: input.slug,
slug: input.name,
name: input.name,
userId: input.userId,
},

View File

@ -31,7 +31,7 @@ import { usePostHog } from "./hooks/usePostHog";
import {
AppContainer,
MainCenteredContainer,
} from "./components/layout/AppLayout";
} from "./components/layout/app-layout";
import { RouteErrorDisplay } from "./components/ErrorDisplay";
import { themeSessionResolver } from "./services/sessionStorage.server";
import {
@ -97,7 +97,7 @@ export function ErrorBoundary() {
<Meta />
<Links />
</head>
<body className="bg-background h-full overflow-hidden">
<body className="bg-background-2 h-full overflow-hidden">
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
@ -123,7 +123,7 @@ function App() {
<Links />
<PreventFlashOnWrongTheme ssrTheme={Boolean(theme)} />
</head>
<body className="bg-background h-full overflow-hidden font-sans">
<body className="bg-background-2 h-full overflow-hidden font-sans">
<Outlet />
<ScrollRestoration />

View File

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

View File

@ -0,0 +1,39 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
createConversation,
CreateConversationSchema,
readConversation,
} from "~/services/conversation.server";
import { z } from "zod";
export const ConversationIdSchema = z.object({
conversationId: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: ConversationIdSchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
},
async ({ authentication, params }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
if (!workspace) {
throw new Error("No workspace found");
}
// Call the service to get the redirect URL
const read = await readConversation(params.conversationId);
return json(read);
},
);
export { action, loader };

View File

@ -0,0 +1,44 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
createConversation,
CreateConversationSchema,
getCurrentConversationRun,
readConversation,
stopConversation,
} from "~/services/conversation.server";
import { z } from "zod";
export const ConversationIdSchema = z.object({
conversationId: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: ConversationIdSchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
},
async ({ authentication, params }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
if (!workspace) {
throw new Error("No workspace found");
}
// Call the service to get the redirect URL
const run = await getCurrentConversationRun(
params.conversationId,
workspace?.id,
);
return json(run);
},
);
export { action, loader };

View File

@ -0,0 +1,41 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
createConversation,
CreateConversationSchema,
readConversation,
stopConversation,
} from "~/services/conversation.server";
import { z } from "zod";
export const ConversationIdSchema = z.object({
conversationId: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: ConversationIdSchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
method: "POST",
},
async ({ authentication, params }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
if (!workspace) {
throw new Error("No workspace found");
}
// Call the service to get the redirect URL
const stop = await stopConversation(params.conversationId, workspace?.id);
return json(stop);
},
);
export { action, loader };

View File

@ -0,0 +1,50 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
getConversation,
deleteConversation,
} from "~/services/conversation.server";
import { z } from "zod";
export const ConversationIdSchema = z.object({
conversationId: z.string(),
});
const { action, loader } = createActionApiRoute(
{
params: ConversationIdSchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
},
async ({ params, authentication, request }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
if (!workspace) {
throw new Error("No workspace found");
}
const method = request.method;
if (method === "GET") {
// Get a conversation by ID
const conversation = await getConversation(params.conversationId);
return json(conversation);
}
if (method === "DELETE") {
// Soft delete a conversation
const deleted = await deleteConversation(params.conversationId);
return json(deleted);
}
// Method not allowed
return new Response("Method Not Allowed", { status: 405 });
},
);
export { action, loader };

View File

@ -0,0 +1,37 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
import {
createConversation,
CreateConversationSchema,
} from "~/services/conversation.server";
const { action, loader } = createActionApiRoute(
{
body: CreateConversationSchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
if (!workspace) {
throw new Error("No workspace found");
}
// Call the service to get the redirect URL
const conversation = await createConversation(
workspace?.id,
authentication.userId,
body,
);
return json(conversation);
},
);
export { action, loader };

View File

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

View File

@ -0,0 +1,32 @@
import { json } from "@remix-run/node";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { OAuthBodySchema } from "~/services/oauth/oauth-utils.server";
import { getRedirectURL } from "~/services/oauth/oauth.server";
import { getWorkspaceByUser } from "~/models/workspace.server";
// This route handles the OAuth redirect URL generation, similar to the NestJS controller
const { action, loader } = createActionApiRoute(
{
body: OAuthBodySchema,
allowJWT: true,
authorization: {
action: "oauth",
},
corsStrategy: "all",
},
async ({ body, authentication, request }) => {
const workspace = await getWorkspaceByUser(authentication.userId);
// Call the service to get the redirect URL
const redirectURL = await getRedirectURL(
body,
authentication.userId,
workspace?.id,
);
return json(redirectURL);
},
);
export { action, loader };

View File

@ -0,0 +1,21 @@
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { callbackHandler } from "~/services/oauth/oauth.server";
import type { CallbackParams } from "~/services/oauth/oauth-utils.server";
// This route handles the OAuth callback, similar to the NestJS controller
const { loader } = createActionApiRoute(
{
allowJWT: false,
corsStrategy: "all",
},
async ({ request }) => {
const url = new URL(request.url);
const params: CallbackParams = {};
for (const [key, value] of url.searchParams.entries()) {
params[key] = value;
}
return await callbackHandler(params, request);
},
);
export { loader };

View File

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

View File

@ -3,7 +3,7 @@ import { useActionData } from "@remix-run/react";
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { useForm } from "@conform-to/react";
import { getFieldsetConstraint, parse } from "@conform-to/zod";
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
import { LoginPageLayout } from "~/components/layout/login-page-layout";
import {
Card,
CardContent,
@ -24,10 +24,6 @@ const schema = z.object({
.string()
.min(3, "Your workspace name must be at least 3 characters")
.max(50),
workspaceSlug: z
.string()
.min(3, "Your workspace slug must be at least 3 characters")
.max(50),
});
export async function action({ request }: ActionFunctionArgs) {
@ -40,11 +36,10 @@ export async function action({ request }: ActionFunctionArgs) {
return json(submission);
}
const { workspaceSlug, workspaceName } = submission.value;
const { workspaceName } = submission.value;
try {
await createWorkspace({
slug: workspaceSlug,
integrations: [],
name: workspaceName,
userId,
@ -109,27 +104,6 @@ export default function ConfirmBasicDetails() {
)}
</div>
<div>
<label
htmlFor="workspaceSlug"
className="text-muted-foreground mb-1 block text-sm"
>
Workspace Slug
</label>
<Input
type="text"
id="workspaceSlug"
placeholder="Give unique workspace slug"
name={fields.workspaceSlug.name}
className="mt-1 block w-full text-base"
/>
{fields.workspaceSlug.error && (
<div className="text-sm text-red-500">
{fields.workspaceSlug.error}
</div>
)}
</div>
<Button
type="submit"
variant="secondary"

View File

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

View File

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

View File

@ -1,29 +1,19 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { parse } from "@conform-to/zod";
import { json } from "@remix-run/node";
import { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Ingest } from "~/components/dashboard/ingest";
import {
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
import { getNodeLinks } from "~/lib/neo4j.server";
import { useTypedLoaderData } from "remix-typedjson";
import { GraphVisualization } from "~/components/graph/graph-visualization";
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();
@ -60,7 +50,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Dashboard() {
const { userId } = useTypedLoaderData<typeof loader>();
const [size, setSize] = useState(15);
// State for nodeLinks and loading
const [nodeLinks, setNodeLinks] = useState<any[] | null>(null);
@ -94,55 +83,18 @@ export default function Dashboard() {
}, [userId]);
return (
<ResizablePanelGroup direction="horizontal">
<ResizablePanel
collapsible={false}
className="h-[calc(100vh_-_20px)] overflow-hidden rounded-md"
order={1}
id="home"
>
<div className="home flex h-full flex-col overflow-y-auto p-3 text-base">
<h3 className="text-lg font-medium">Graph</h3>
<p className="text-muted-foreground"> Your memory graph </p>
<div className="bg-background-3 mt-2 flex grow items-center justify-center rounded">
{loading ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualization triplets={nodeLinks} />
)}
<div className="home flex h-[calc(100vh_-_60px)] flex-col overflow-y-auto p-3 text-base">
<div className="flex grow items-center justify-center rounded">
{loading ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-b-2 border-gray-400" />
<span className="text-muted-foreground">Loading graph...</span>
</div>
</div>
</ResizablePanel>
<ResizableHandle className="bg-border w-[0.5px]" />
<ResizablePanel
className="overflow-auto"
collapsible={false}
maxSize={50}
minSize={25}
defaultSize={size}
onResize={(size) => setSize(size)}
order={2}
id="rightScreen"
>
<Tabs defaultValue="ingest" className="p-3 text-base">
<TabsList>
<TabsTrigger value="ingest">Add</TabsTrigger>
<TabsTrigger value="retrieve">Retrieve</TabsTrigger>
</TabsList>
<TabsContent value="ingest">
<Ingest />
</TabsContent>
<TabsContent value="retrieve">
<Search />
</TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
) : (
typeof window !== "undefined" &&
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
)}
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { AppSidebar } from "~/components/sidebar/app-sidebar";
import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar";
import { SiteHeader } from "~/components/ui/header";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
@ -32,16 +33,19 @@ export default function Home() {
{
"--sidebar-width": "calc(var(--spacing) * 54)",
"--header-height": "calc(var(--spacing) * 12)",
background: "var(--background)",
background: "var(--background-2)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset className="bg-background-2">
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex h-full flex-col">
<Outlet />
<SidebarInset className="bg-background h-[100vh] py-2 pr-2">
<div className="bg-background-2 h-full rounded-md">
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex h-full flex-col">
<Outlet />
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -1,213 +1,213 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { getUserQueue, type IngestBodyRequest } from "~/lib/ingest.server";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.service";
import { IngestionStatus, type Prisma } from "@core/database";
// import { json } from "@remix-run/node";
// import { z } from "zod";
// import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
// import { getUserQueue, type IngestBodyRequest } from "~/lib/ingest.server";
// import { prisma } from "~/db.server";
// import { logger } from "~/services/logger.service";
// import { IngestionStatus, type Prisma } from "@core/database";
const ReingestionBodyRequest = z.object({
userId: z.string().optional(),
spaceId: z.string().optional(),
dryRun: z.boolean().optional().default(false),
});
// const ReingestionBodyRequest = z.object({
// userId: z.string().optional(),
// spaceId: z.string().optional(),
// dryRun: z.boolean().optional().default(false),
// });
type ReingestionRequest = z.infer<typeof ReingestionBodyRequest>;
// type ReingestionRequest = z.infer<typeof ReingestionBodyRequest>;
async function getCompletedIngestionsByUser(userId?: string, spaceId?: string) {
const whereClause: Prisma.IngestionQueueWhereInput = {
status: IngestionStatus.COMPLETED,
};
// async function getCompletedIngestionsByUser(userId?: string, spaceId?: string) {
// const whereClause: Prisma.IngestionQueueWhereInput = {
// status: IngestionStatus.COMPLETED,
// };
if (userId) {
whereClause.workspace = {
userId: userId,
};
}
// if (userId) {
// whereClause.workspace = {
// userId: userId,
// };
// }
if (spaceId) {
whereClause.spaceId = spaceId;
}
// if (spaceId) {
// whereClause.spaceId = spaceId;
// }
const ingestions = await prisma.ingestionQueue.findMany({
where: whereClause,
include: {
workspace: {
include: {
user: true,
},
},
},
orderBy: [
{ createdAt: 'asc' }, // Maintain temporal order
],
});
// const ingestions = await prisma.ingestionQueue.findMany({
// where: whereClause,
// include: {
// workspace: {
// include: {
// user: true,
// },
// },
// },
// orderBy: [
// { createdAt: 'asc' }, // Maintain temporal order
// ],
// });
return ingestions;
}
// return ingestions;
// }
async function getAllUsers() {
const users = await prisma.user.findMany({
include: {
Workspace: true,
},
});
return users.filter(user => user.Workspace); // Only users with workspaces
}
// async function getAllUsers() {
// const users = await prisma.user.findMany({
// include: {
// Workspace: true,
// },
// });
// return users.filter(user => user.Workspace); // Only users with workspaces
// }
async function reingestionForUser(userId: string, spaceId?: string, dryRun = false) {
const ingestions = await getCompletedIngestionsByUser(userId, spaceId);
logger.info(`Found ${ingestions.length} completed ingestions for user ${userId}${spaceId ? ` in space ${spaceId}` : ''}`);
// async function reingestionForUser(userId: string, spaceId?: string, dryRun = false) {
// const ingestions = await getCompletedIngestionsByUser(userId, spaceId);
if (dryRun) {
return {
userId,
ingestionCount: ingestions.length,
ingestions: ingestions.map(ing => ({
id: ing.id,
createdAt: ing.createdAt,
spaceId: ing.spaceId,
data: {
episodeBody: (ing.data as any)?.episodeBody?.substring(0, 100) +
((ing.data as any)?.episodeBody?.length > 100 ? '...' : ''),
source: (ing.data as any)?.source,
referenceTime: (ing.data as any)?.referenceTime,
},
})),
};
}
// logger.info(`Found ${ingestions.length} completed ingestions for user ${userId}${spaceId ? ` in space ${spaceId}` : ''}`);
// Queue ingestions in temporal order (already sorted by createdAt ASC)
const queuedJobs = [];
const ingestionQueue = getUserQueue(userId);
for (const ingestion of ingestions) {
try {
// Parse the original data and add reingestion metadata
const originalData = ingestion.data as z.infer<typeof IngestBodyRequest>;
const reingestionData = {
...originalData,
source: `reingest-${originalData.source}`,
metadata: {
...originalData.metadata,
isReingestion: true,
originalIngestionId: ingestion.id,
},
};
// if (dryRun) {
// return {
// userId,
// ingestionCount: ingestions.length,
// ingestions: ingestions.map(ing => ({
// id: ing.id,
// createdAt: ing.createdAt,
// spaceId: ing.spaceId,
// data: {
// episodeBody: (ing.data as any)?.episodeBody?.substring(0, 100) +
// ((ing.data as any)?.episodeBody?.length > 100 ? '...' : ''),
// source: (ing.data as any)?.source,
// referenceTime: (ing.data as any)?.referenceTime,
// },
// })),
// };
// }
const jobDetails = await ingestionQueue.add(
`ingest-user-${userId}`,
{
queueId: ingestion.id,
spaceId: ingestion.spaceId,
userId: userId,
body: ingestion.data,
},
{
jobId: `${userId}-${Date.now()}`,
},
);
// // Queue ingestions in temporal order (already sorted by createdAt ASC)
// const queuedJobs = [];
// const ingestionQueue = getUserQueue(userId);
// for (const ingestion of ingestions) {
// try {
// // Parse the original data and add reingestion metadata
// const originalData = ingestion.data as z.infer<typeof IngestBodyRequest>;
queuedJobs.push({id: jobDetails.id});
} catch (error) {
logger.error(`Failed to queue ingestion ${ingestion.id} for user ${userId}:`, {error});
}
}
// const reingestionData = {
// ...originalData,
// source: `reingest-${originalData.source}`,
// metadata: {
// ...originalData.metadata,
// isReingestion: true,
// originalIngestionId: ingestion.id,
// },
// };
return {
userId,
ingestionCount: ingestions.length,
queuedJobsCount: queuedJobs.length,
queuedJobs,
};
}
// const jobDetails = await ingestionQueue.add(
// `ingest-user-${userId}`,
// {
// queueId: ingestion.id,
// spaceId: ingestion.spaceId,
// userId: userId,
// body: ingestion.data,
// },
// {
// jobId: `${userId}-${Date.now()}`,
// },
// );
const { action, loader } = createActionApiRoute(
{
body: ReingestionBodyRequest,
allowJWT: true,
authorization: {
action: "reingest",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const { userId, spaceId, dryRun } = body;
// queuedJobs.push({id: jobDetails.id});
// } catch (error) {
// logger.error(`Failed to queue ingestion ${ingestion.id} for user ${userId}:`, {error});
// }
// }
try {
// Check if the user is an admin
const user = await prisma.user.findUnique({
where: { id: authentication.userId }
});
// return {
// userId,
// ingestionCount: ingestions.length,
// queuedJobsCount: queuedJobs.length,
// queuedJobs,
// };
// }
if (!user || user.admin !== true) {
logger.warn("Unauthorized reingest attempt", {
requestUserId: authentication.userId,
});
return json(
{
success: false,
error: "Unauthorized: Only admin users can perform reingestion"
},
{ status: 403 }
);
}
if (userId) {
// Reingest for specific user
const result = await reingestionForUser(userId, spaceId, dryRun);
return json({
success: true,
type: "single_user",
result,
});
} else {
// Reingest for all users
const users = await getAllUsers();
const results = [];
// const { action, loader } = createActionApiRoute(
// {
// body: ReingestionBodyRequest,
// allowJWT: true,
// authorization: {
// action: "reingest",
// },
// corsStrategy: "all",
// },
// async ({ body, authentication }) => {
// const { userId, spaceId, dryRun } = body;
logger.info(`Starting reingestion for ${users.length} users`);
// try {
// // Check if the user is an admin
// const user = await prisma.user.findUnique({
// where: { id: authentication.userId }
// });
for (const user of users) {
try {
const result = await reingestionForUser(user.id, spaceId, dryRun);
results.push(result);
if (!dryRun) {
// Add small delay between users to prevent overwhelming the system
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
logger.error(`Failed to reingest for user ${user.id}:`, {error});
results.push({
userId: user.id,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
// if (!user || user.admin !== true) {
// logger.warn("Unauthorized reingest attempt", {
// requestUserId: authentication.userId,
// });
// return json(
// {
// success: false,
// error: "Unauthorized: Only admin users can perform reingestion"
// },
// { status: 403 }
// );
// }
// if (userId) {
// // Reingest for specific user
// const result = await reingestionForUser(userId, spaceId, dryRun);
// return json({
// success: true,
// type: "single_user",
// result,
// });
// } else {
// // Reingest for all users
// const users = await getAllUsers();
// const results = [];
return json({
success: true,
type: "all_users",
totalUsers: users.length,
results,
summary: {
totalIngestions: results.reduce((sum, r) => sum, 0),
totalQueuedJobs: results.reduce((sum, r) => sum, 0),
},
});
}
} catch (error) {
logger.error("Reingestion failed:", {error});
return json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}
);
// logger.info(`Starting reingestion for ${users.length} users`);
export { action, loader };
// for (const user of users) {
// try {
// const result = await reingestionForUser(user.id, spaceId, dryRun);
// results.push(result);
// if (!dryRun) {
// // Add small delay between users to prevent overwhelming the system
// await new Promise(resolve => setTimeout(resolve, 1000));
// }
// } catch (error) {
// logger.error(`Failed to reingest for user ${user.id}:`, {error});
// results.push({
// userId: user.id,
// error: error instanceof Error ? error.message : "Unknown error",
// });
// }
// }
// return json({
// success: true,
// type: "all_users",
// totalUsers: users.length,
// results,
// summary: {
// totalIngestions: results.reduce((sum, r) => sum, 0),
// totalQueuedJobs: results.reduce((sum, r) => sum, 0),
// },
// });
// }
// } catch (error) {
// logger.error("Reingestion failed:", {error});
// return json(
// {
// success: false,
// error: error instanceof Error ? error.message : "Unknown error",
// },
// { status: 500 }
// );
// }
// }
// );
// export { action, loader };

View File

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

View File

@ -86,7 +86,7 @@ export default function API() {
const [name, setName] = useState("");
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
fetcher.submit({ name }, { method: "POST", action: "/home/api" });
fetcher.submit({ name }, { method: "POST", action: "/settings/api" });
setOpen(false);
setShowToken(true);
};

View File

@ -0,0 +1,112 @@
import {
ArrowLeft,
Brain,
Building,
Clock,
Code,
User,
Workflow,
} from "lucide-react";
import React from "react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarProvider,
} from "~/components/ui/sidebar";
import { Button } from "~/components/ui";
import { cn } from "~/lib/utils";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUser, requireWorkpace } from "~/services/session.server";
import { typedjson } from "remix-typedjson";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { Outlet, useLocation, useNavigate } from "@remix-run/react";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const workspace = await requireWorkpace(request);
return typedjson(
{
user,
workspace,
},
{
headers: {
"Set-Cookie": await commitSession(await clearRedirectTo(request)),
},
},
);
};
export default function Settings() {
const location = useLocation();
const data = {
nav: [
{ name: "Workspace", icon: Building },
{ name: "Preferences", icon: User },
{ name: "API", icon: Code },
],
};
const navigate = useNavigate();
const gotoHome = () => {
navigate("/home/dashboard");
};
return (
<div className="bg-background h-full w-full overflow-hidden p-0">
<SidebarProvider className="items-start">
<Sidebar collapsible="none" className="hidden w-[180px] md:flex">
<SidebarHeader className="flex justify-start pb-0">
<Button
variant="link"
className="flex w-fit gap-2"
onClick={gotoHome}
>
<ArrowLeft size={14} />
Back to app
</Button>
</SidebarHeader>
<SidebarContent className="bg-background">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{data.nav.map((item) => (
<SidebarMenuItem key={item.name}>
<Button
variant="secondary"
isActive={location.pathname.includes(
item.name.toLowerCase(),
)}
onClick={() =>
navigate(`/settings/${item.name.toLowerCase()}`)
}
className={cn("flex w-fit min-w-0 justify-start gap-1")}
>
<item.icon size={18} />
<span>{item.name}</span>
</Button>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex h-[100vh] flex-1 flex-col overflow-hidden p-2 pl-0">
<div className="bg-background-2 flex h-full flex-1 flex-col overflow-y-auto rounded-md">
<Outlet />
</div>
</main>
</SidebarProvider>
</div>
);
}

View File

@ -1,6 +1,3 @@
import { SignJWT, errors, jwtVerify } from "jose";
import { env } from "~/env.server";
import { findUserByToken } from "~/models/personal-token.server";
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
@ -85,7 +82,7 @@ export async function authenticateApiKeyWithFailure(
}
export function isSecretApiKey(key: string) {
return key.startsWith("tr_");
return key.startsWith("rc_");
}
export function getApiKeyFromRequest(request: Request) {

View File

@ -0,0 +1,361 @@
import { UserTypeEnum } from "@core/types";
import { auth, runs, tasks } from "@trigger.dev/sdk/v3";
import { prisma } from "~/db.server";
import { createConversationTitle } from "~/trigger/conversation/create-conversation-title";
import { z } from "zod";
import { type ConversationHistory } from "@prisma/client";
export const CreateConversationSchema = z.object({
message: z.string(),
title: z.string().optional(),
conversationId: z.string().optional(),
userType: z.nativeEnum(UserTypeEnum).optional(),
});
export type CreateConversationDto = z.infer<typeof CreateConversationSchema>;
// Create a new conversation
export async function createConversation(
workspaceId: string,
userId: string,
conversationData: CreateConversationDto,
) {
const { title, conversationId, ...otherData } = conversationData;
if (conversationId) {
// Add a new message to an existing conversation
const conversationHistory = await prisma.conversationHistory.create({
data: {
...otherData,
userType: otherData.userType || UserTypeEnum.User,
...(userId && {
user: {
connect: { id: userId },
},
}),
conversation: {
connect: { id: conversationId },
},
},
include: {
conversation: true,
},
});
const context = await getConversationContext(conversationHistory.id);
const handler = await tasks.trigger(
"chat",
{
conversationHistoryId: conversationHistory.id,
conversationId: conversationHistory.conversation.id,
context,
},
{ tags: [conversationHistory.id, workspaceId, conversationId] },
);
return {
id: handler.id,
token: handler.publicAccessToken,
conversationId: conversationHistory.conversation.id,
conversationHistoryId: conversationHistory.id,
};
}
// Create a new conversation and its first message
const conversation = await prisma.conversation.create({
data: {
workspaceId,
userId,
title:
title?.substring(0, 100) ?? conversationData.message.substring(0, 100),
ConversationHistory: {
create: {
userId,
userType: otherData.userType || UserTypeEnum.User,
...otherData,
},
},
},
include: {
ConversationHistory: true,
},
});
const conversationHistory = conversation.ConversationHistory[0];
const context = await getConversationContext(conversationHistory.id);
// Trigger conversation title task
await tasks.trigger<typeof createConversationTitle>(
createConversationTitle.id,
{
conversationId: conversation.id,
message: conversationData.message,
},
{ tags: [conversation.id, workspaceId] },
);
const handler = await tasks.trigger(
"chat",
{
conversationHistoryId: conversationHistory.id,
conversationId: conversation.id,
context,
},
{ tags: [conversationHistory.id, workspaceId, conversation.id] },
);
return {
id: handler.id,
token: handler.publicAccessToken,
conversationId: conversation.id,
conversationHistoryId: conversationHistory.id,
};
}
// Get a conversation by ID
export async function getConversation(conversationId: string) {
return prisma.conversation.findUnique({
where: { id: conversationId },
});
}
// Delete a conversation (soft delete)
export async function deleteConversation(conversationId: string) {
return prisma.conversation.update({
where: { id: conversationId },
data: {
deleted: new Date().toISOString(),
},
});
}
// Mark a conversation as read
export async function readConversation(conversationId: string) {
return prisma.conversation.update({
where: { id: conversationId },
data: { unread: false },
});
}
export async function getCurrentConversationRun(
conversationId: string,
workspaceId: string,
) {
const conversationHistory = await prisma.conversationHistory.findFirst({
where: {
conversationId,
conversation: {
workspaceId,
},
},
orderBy: {
updatedAt: "desc",
},
});
if (!conversationHistory) {
throw new Error("No run found");
}
const response = await runs.list({
tag: [conversationId, conversationHistory.id],
status: ["QUEUED", "EXECUTING"],
limit: 1,
});
const run = response.data[0];
if (!run) {
return undefined;
}
const publicToken = await auth.createPublicToken({
scopes: {
read: {
runs: [run.id],
},
},
});
return {
id: run.id,
token: publicToken,
conversationId,
conversationHistoryId: conversationHistory.id,
};
}
export async function stopConversation(
conversationId: string,
workspaceId: string,
) {
const conversationHistory = await prisma.conversationHistory.findFirst({
where: {
conversationId,
conversation: {
workspaceId,
},
},
orderBy: {
updatedAt: "desc",
},
});
if (!conversationHistory) {
throw new Error("No run found");
}
const response = await runs.list({
tag: [conversationId, conversationHistory.id],
status: ["QUEUED", "EXECUTING"],
limit: 1,
});
const run = response.data[0];
if (!run) {
await prisma.conversation.update({
where: {
id: conversationId,
},
data: {
status: "failed",
},
});
return undefined;
}
return await runs.cancel(run.id);
}
export async function getConversationContext(
conversationHistoryId: string,
): Promise<{
previousHistory: ConversationHistory[];
}> {
const conversationHistory = await prisma.conversationHistory.findUnique({
where: { id: conversationHistoryId },
include: { conversation: true },
});
if (!conversationHistory) {
return {
previousHistory: [],
};
}
// Get previous conversation history message and response
let previousHistory: ConversationHistory[] = [];
if (conversationHistory.conversationId) {
previousHistory = await prisma.conversationHistory.findMany({
where: {
conversationId: conversationHistory.conversationId,
id: {
not: conversationHistoryId,
},
deleted: null,
},
orderBy: {
createdAt: "asc",
},
});
}
return {
previousHistory,
};
}
export const getConversationAndHistory = async (
conversationId: string,
userId: string,
) => {
const conversation = await prisma.conversation.findFirst({
where: {
id: conversationId,
},
include: {
ConversationHistory: true,
},
});
return conversation;
};
export const GetConversationsListSchema = z.object({
page: z.string().optional().default("1"),
limit: z.string().optional().default("20"),
search: z.string().optional(),
});
export type GetConversationsListDto = z.infer<typeof GetConversationsListSchema>;
export async function getConversationsList(
workspaceId: string,
userId: string,
params: GetConversationsListDto,
) {
const page = parseInt(params.page);
const limit = parseInt(params.limit);
const skip = (page - 1) * limit;
const where = {
workspaceId,
userId,
deleted: null,
...(params.search && {
OR: [
{
title: {
contains: params.search,
mode: "insensitive" as const,
},
},
{
ConversationHistory: {
some: {
message: {
contains: params.search,
mode: "insensitive" as const,
},
},
},
},
],
}),
};
const [conversations, total] = await Promise.all([
prisma.conversation.findMany({
where,
include: {
ConversationHistory: {
take: 1,
orderBy: {
createdAt: "desc",
},
},
},
orderBy: {
updatedAt: "desc",
},
skip,
take: limit,
}),
prisma.conversation.count({ where }),
]);
return {
conversations,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1,
},
};
}

View File

@ -5,9 +5,7 @@ import {
type MailTransportOptions,
} from "emails";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
import type { AuthUser } from "./authUser";
import { logger } from "./logger.service";
import { singleton } from "~/utils/singleton";

View File

@ -0,0 +1,88 @@
import { tasks } from "@trigger.dev/sdk/v3";
import { getOrCreatePersonalAccessToken } from "./personalAccessToken.server";
import { logger } from "./logger.service";
import { type integrationRun } from "~/trigger/integrations/integration-run";
import type { IntegrationDefinitionV2 } from "@core/database";
/**
* Prepares the parameters for triggering an integration.
* If userId is provided, gets or creates a personal access token for the user.
*/
async function prepareIntegrationTrigger(
integrationDefinition: IntegrationDefinitionV2,
userId?: string,
workspaceId?: string,
) {
logger.info(`Loading integration ${integrationDefinition.slug}`);
let pat = "";
if (userId) {
// Use the integration slug as the token name for uniqueness
const tokenResult = await getOrCreatePersonalAccessToken({
name: integrationDefinition.slug ?? "integration",
userId,
});
pat = tokenResult.token ?? "";
}
return {
integrationDefinition,
pat,
};
}
/**
* Triggers an integration run asynchronously.
*/
export async function runIntegrationTriggerAsync(
integrationDefinition: IntegrationDefinitionV2,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
) {
const params = await prepareIntegrationTrigger(
integrationDefinition,
userId,
workspaceId,
);
return await tasks.trigger<typeof integrationRun>("integration-run", {
...params,
event,
});
}
/**
* Triggers an integration run and waits for completion.
*/
export async function runIntegrationTrigger(
integrationDefinition: IntegrationDefinitionV2,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
) {
const params = await prepareIntegrationTrigger(
integrationDefinition,
userId,
workspaceId,
);
const response = await tasks.triggerAndPoll<typeof integrationRun>(
"integration-run",
{
...params,
integrationAccount: event.integrationAccount,
event: event.event,
eventBody: event.eventBody,
},
);
if (response.status === "COMPLETED") {
return response.output;
}
throw new Error(`Integration trigger failed with status: ${response.status}`);
}

View File

@ -0,0 +1,24 @@
import { prisma } from "~/db.server";
/**
* Get all integration definitions available to a workspace.
* Returns both global (workspaceId: null) and workspace-specific definitions.
*/
export async function getIntegrationDefinitions(workspaceId: string) {
return prisma.integrationDefinitionV2.findMany({
where: {
OR: [{ workspaceId: null }, { workspaceId }],
},
});
}
/**
* Get a single integration definition by its ID.
*/
export async function getIntegrationDefinitionWithId(
integrationDefinitionId: string,
) {
return prisma.integrationDefinitionV2.findUnique({
where: { id: integrationDefinitionId },
});
}

View File

@ -1,8 +1,6 @@
import { openai } from "@ai-sdk/openai";
import { type CoreMessage, embed } from "ai";
import {
EpisodeType,
LLMModelEnum,
type AddEpisodeParams,
type EntityNode,
type EpisodicNode,
@ -58,7 +56,7 @@ export class KnowledgeGraphService {
}
// Default to using Ollama
const ollamaUrl = process.env.OLLAMA_URL;
const ollamaUrl = env.OLLAMA_URL;
const model = env.EMBEDDING_MODEL;
const ollama = createOllama({

View File

@ -0,0 +1,155 @@
import { type OAuth2Params } from "@core/types";
import { IsBoolean, IsString } from "class-validator";
import type { IntegrationDefinitionV2 } from "@core/database";
import { z } from "zod";
export interface RedirectURLParams {
workspaceSlug: string;
integrationOAuthAppName: string;
config: string;
}
export interface SessionRecord {
integrationDefinitionId: string;
config: OAuth2Params;
redirectURL: string;
workspaceId: string;
accountIdentifier?: string;
integrationKeys?: string;
personal: boolean;
userId?: string;
}
export class OAuthBodyInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config?: any;
@IsString()
redirectURL: string;
@IsBoolean()
personal: boolean = false;
@IsString()
integrationDefinitionId: string;
}
export const OAuthBodySchema = z.object({
config: z.any().optional(),
redirectURL: z.string(),
personal: z.boolean().default(false),
integrationDefinitionId: z.string(),
});
export type CallbackParams = Record<string, string>;
export interface ProviderConfig {
client_id: string;
client_secret: string;
scopes: string;
}
const enum ProviderAuthModes {
"OAuth2" = "OAuth2",
}
export interface ProviderTemplate extends OAuth2Params {
auth_mode: ProviderAuthModes;
}
export enum OAuthAuthorizationMethod {
BODY = "body",
HEADER = "header",
}
export enum OAuthBodyFormat {
FORM = "form",
JSON = "json",
}
export interface ProviderTemplateOAuth2 extends ProviderTemplate {
auth_mode: ProviderAuthModes.OAuth2;
disable_pkce?: boolean; // Defaults to false (=PKCE used) if not provided
token_params?: {
grant_type?: "authorization_code" | "client_credentials";
};
refresh_params?: {
grant_type: "refresh_token";
};
authorization_method?: OAuthAuthorizationMethod;
body_format?: OAuthBodyFormat;
refresh_url?: string;
token_request_auth_method?: "basic";
}
/**
* A helper function to interpolate a string.
* interpolateString('Hello ${name} of ${age} years", {name: 'Tester', age: 234}) -> returns 'Hello Tester of age 234 years'
*
* @remarks
* Copied from https://stackoverflow.com/a/1408373/250880
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function interpolateString(str: string, replacers: Record<string, any>) {
return str.replace(/\${([^{}]*)}/g, (a, b) => {
const r = replacers[b];
return typeof r === "string" || typeof r === "number" ? (r as string) : a; // Typecast needed to make TypeScript happy
});
}
export function getSimpleOAuth2ClientConfig(
providerConfig: ProviderConfig,
template: ProviderTemplate,
connectionConfig: OAuth2Params,
) {
const tokenUrl = new URL(
interpolateString(template.token_url, connectionConfig),
);
const authorizeUrl = new URL(
interpolateString(template.authorization_url, connectionConfig),
);
const headers = { "User-Agent": "Sol" };
const authConfig = template as ProviderTemplateOAuth2;
return {
client: {
id: providerConfig.client_id,
secret: providerConfig.client_secret,
},
auth: {
tokenHost: tokenUrl.origin,
tokenPath: tokenUrl.pathname,
authorizeHost: authorizeUrl.origin,
authorizePath: authorizeUrl.pathname,
},
http: { headers },
options: {
authorizationMethod:
authConfig.authorization_method || OAuthAuthorizationMethod.BODY,
bodyFormat: authConfig.body_format || OAuthBodyFormat.FORM,
scopeSeparator: template.scope_separator || " ",
},
};
}
export async function getTemplate(
integrationDefinition: IntegrationDefinitionV2,
): Promise<ProviderTemplate> {
const spec = integrationDefinition.spec as any;
const template: ProviderTemplate = spec.auth.OAuth2 as ProviderTemplate;
if (!template) {
throw new Error(
`This extension doesn't support OAuth. Reach out to us if you need support for this extension`,
);
}
return template;
}

View File

@ -0,0 +1,245 @@
import { IntegrationPayloadEventType, type OAuth2Params } from "@core/types";
import * as simpleOauth2 from "simple-oauth2";
import { tasks } from "@trigger.dev/sdk/v3";
import {
getSimpleOAuth2ClientConfig,
getTemplate,
type OAuthBodyInterface,
type ProviderTemplateOAuth2,
type SessionRecord,
} from "./oauth-utils.server";
import { getIntegrationDefinitionWithId } from "../integrationDefinition.server";
import { type scheduler } from "~/trigger/integrations/scheduler";
import { logger } from "../logger.service";
import { runIntegrationTrigger } from "../integration.server";
import type { IntegrationDefinitionV2 } from "@core/database";
import { env } from "~/env.server";
// Use process.env for config in Remix
const CALLBACK_URL = process.env.OAUTH_CALLBACK_URL ?? "";
// Session store (in-memory, for single server)
const session: Record<string, SessionRecord> = {};
export type CallbackParams = Record<string, string>;
// Remix-style callback handler
// Accepts a Remix LoaderFunctionArgs-like object: { request }
export async function callbackHandler(
params: CallbackParams,
request: Request,
) {
if (!params.state) {
throw new Error("No state found");
}
const sessionRecord = session[params.state];
// Delete the session once it's used
delete session[params.state];
if (!sessionRecord) {
throw new Error("No session found");
}
const integrationDefinition = await getIntegrationDefinitionWithId(
sessionRecord.integrationDefinitionId,
);
const template = (await getTemplate(
integrationDefinition as IntegrationDefinitionV2,
)) as ProviderTemplateOAuth2;
if (integrationDefinition === null) {
const errorMessage = "No matching integration definition found";
return new Response(null, {
status: 302,
headers: {
Location: `${sessionRecord.redirectURL}?success=false&error=${encodeURIComponent(
errorMessage,
)}`,
},
});
}
let additionalTokenParams: Record<string, string> = {};
if (template.token_params !== undefined) {
const deepCopy = JSON.parse(JSON.stringify(template.token_params));
additionalTokenParams = deepCopy;
}
if (template.refresh_params) {
additionalTokenParams = template.refresh_params;
}
const headers: Record<string, string> = {};
const integrationConfig = integrationDefinition.config as any;
const integrationSpec = integrationDefinition.spec as any;
if (template.token_request_auth_method === "basic") {
headers["Authorization"] = `Basic ${Buffer.from(
`${integrationConfig?.clientId}:${integrationConfig.clientSecret}`,
).toString("base64")}`;
}
const accountIdentifier = sessionRecord.accountIdentifier
? `&accountIdentifier=${encodeURIComponent(sessionRecord.accountIdentifier)}`
: "";
const integrationKeys = sessionRecord.integrationKeys
? `&integrationKeys=${encodeURIComponent(sessionRecord.integrationKeys)}`
: "";
try {
const scopes = (integrationSpec.auth.OAuth2 as OAuth2Params)
.scopes as string[];
const simpleOAuthClient = new simpleOauth2.AuthorizationCode(
getSimpleOAuth2ClientConfig(
{
client_id: integrationConfig.clientId,
client_secret: integrationConfig.clientSecret,
scopes: scopes.join(","),
},
template,
sessionRecord.config,
),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tokensResponse: any = await simpleOAuthClient.getToken(
{
code: params.code as string,
redirect_uri: CALLBACK_URL,
...additionalTokenParams,
},
{
headers,
},
);
const integrationAccount = await runIntegrationTrigger(
integrationDefinition,
{
event: IntegrationPayloadEventType.INTEGRATION_ACCOUNT_CREATED,
eventBody: {
oauthResponse: tokensResponse.token,
oauthParams: {
...params,
redirect_uri: CALLBACK_URL,
},
integrationDefinition,
},
},
sessionRecord.userId,
sessionRecord.workspaceId,
);
await tasks.trigger<typeof scheduler>("scheduler", {
integrationAccountId: integrationAccount.id,
});
return new Response(null, {
status: 302,
headers: {
Location: `${sessionRecord.redirectURL}?success=true&integrationName=${encodeURIComponent(
integrationDefinition.name,
)}${accountIdentifier}${integrationKeys}`,
},
});
} catch (e: any) {
logger.error(e);
return new Response(null, {
status: 302,
headers: {
Location: `${sessionRecord.redirectURL}?success=false&error=${encodeURIComponent(
e.message,
)}${accountIdentifier}${integrationKeys}`,
},
});
}
}
export async function getRedirectURL(
oAuthBody: OAuthBodyInterface,
userId: string,
workspaceId?: string,
specificScopes?: string,
) {
const { integrationDefinitionId, personal } = oAuthBody;
const redirectURL = `${env.APP_ORIGIN}/integrations`;
logger.info(
`We got OAuth request for ${workspaceId}: ${integrationDefinitionId}`,
);
const integrationDefinition = await getIntegrationDefinitionWithId(
integrationDefinitionId,
);
if (!integrationDefinition) {
throw new Error("No integration definition ");
}
const spec = integrationDefinition.spec as any;
const externalConfig = spec.auth.OAuth2 as OAuth2Params;
const template = await getTemplate(integrationDefinition);
const scopesString =
specificScopes || (externalConfig.scopes as string[]).join(",");
const additionalAuthParams = template.authorization_params || {};
const integrationConfig = integrationDefinition.config as any;
try {
const simpleOAuthClient = new simpleOauth2.AuthorizationCode(
getSimpleOAuth2ClientConfig(
{
client_id: integrationConfig.clientId,
client_secret: integrationConfig.clientSecret,
scopes: scopesString,
},
template,
externalConfig,
),
);
const uniqueId = Date.now().toString(36);
session[uniqueId] = {
integrationDefinitionId: integrationDefinition.id,
redirectURL,
workspaceId: workspaceId as string,
config: externalConfig,
userId,
personal,
};
const scopes = [
...scopesString.split(","),
...(template.default_scopes || []),
];
const scopeIdentifier = externalConfig.scope_identifier ?? "scope";
const authorizationUri = simpleOAuthClient.authorizeURL({
redirect_uri: CALLBACK_URL,
[scopeIdentifier]: scopes.join(template.scope_separator || " "),
state: uniqueId,
...additionalAuthParams,
});
logger.debug(
`OAuth 2.0 for ${integrationDefinition.name} - redirecting to: ${authorizationUri}`,
);
return {
status: 200,
redirectURL: authorizationUri,
};
} catch (e: any) {
logger.warn(e);
throw new Error(e.message);
}
}

View File

@ -268,6 +268,58 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
return token;
}
/** Get or create a PersonalAccessToken for the given name and userId.
* If one exists (not revoked), return it (without the unencrypted token).
* If not, create a new one and return it (with the unencrypted token).
* We only ever return the unencrypted token once, on creation.
*/
export async function getOrCreatePersonalAccessToken({
name,
userId,
}: CreatePersonalAccessTokenOptions) {
// Try to find an existing, non-revoked token
const existing = await prisma.personalAccessToken.findFirst({
where: {
name,
userId,
revokedAt: null,
},
});
if (existing) {
// Do not return the unencrypted token if it already exists
return {
id: existing.id,
name: existing.name,
userId: existing.userId,
obfuscatedToken: existing.obfuscatedToken,
// token is not returned
};
}
// Create a new token
const token = createToken();
const encryptedToken = encryptToken(token);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
export async function createPersonalAccessToken({
name,
@ -306,7 +358,7 @@ function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
/** Obfuscates all but the first and last 4 characters of the token, so it looks like rc_pat_bhbd•••••••••••••••••••fd4a */
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;

View File

@ -1,4 +1,5 @@
import type { User } from "~/models/user.server";
import { createWorkspace } from "~/models/workspace.server";
import { singleton } from "~/utils/singleton";
export async function postAuthentication({
@ -10,5 +11,11 @@ export async function postAuthentication({
loginMethod: User["authenticationMethod"];
isNewUser: boolean;
}) {
// console.log(user);
if (user.name && isNewUser && loginMethod === "GOOGLE") {
await createWorkspace({
name: user.name,
userId: user.id,
integrations: [],
});
}
}

View File

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

View File

@ -12,7 +12,7 @@
--foreground: oklch(0% 0 0);
--popover: oklch(93.05% 0 0);
--popover-foreground: oklch(0% 0 0);
--primary: oklch(54% 0.1789 271);
--primary: oklch(60% 0.13 30);
--primary-foreground: oklch(100% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(0% 0 0);
@ -49,7 +49,7 @@
--foreground: oklch(92.8% 0 0);
--popover: oklch(28.5% 0 0);
--popover-foreground: oklch(92.8% 0 0);
--primary: oklch(54% 0.1789 271);
--primary: oklch(60% 0.13 30);
--primary-foreground: oklch(92.8% 0 0);
--secondary: 210 40% 96.1%;
--secondary-foreground: oklch(92.8% 0 0);
@ -322,8 +322,49 @@
@layer base {
* {
@apply border-border outline-ring/50;
--header-height: 44px;
}
body {
@apply bg-background 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;
}
}

View File

View File

@ -0,0 +1,561 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ActionStatusEnum } from "@core/types";
import { logger } from "@trigger.dev/sdk/v3";
import {
type CoreMessage,
type DataContent,
jsonSchema,
tool,
type ToolSet,
} from "ai";
import axios from "axios";
import Handlebars from "handlebars";
import { REACT_SYSTEM_PROMPT, REACT_USER_PROMPT } from "./prompt";
import { generate, processTag } from "./stream-utils";
import { type AgentMessage, AgentMessageType, Message } from "./types";
import { type MCP } from "../utils/mcp";
import {
type ExecutionState,
type HistoryStep,
type Resource,
type TotalCost,
} from "../utils/types";
import { flattenObject } from "../utils/utils";
import { searchMemory, addMemory } from "./memory-utils";
interface LLMOutputInterface {
response: AsyncGenerator<
| string
| {
type: string;
toolName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args?: any;
toolCallId?: string;
message?: string;
},
any,
any
>;
}
const progressUpdateTool = tool({
description:
"Send a progress update to the user about what has been discovered or will be done next in a crisp and user friendly way no technical terms",
parameters: jsonSchema({
type: "object",
properties: {
message: {
type: "string",
description: "The progress update message to send to the user",
},
},
required: ["message"],
additionalProperties: false,
}),
});
const searchMemoryTool = tool({
description:
"Search the user's memory graph for episodes or statements based on a query",
parameters: jsonSchema({
type: "object",
properties: {
query: {
type: "string",
description: "The search query in third person perspective",
},
validAt: {
type: "string",
description: "The valid at time in ISO format",
},
startTime: {
type: "string",
description: "The start time in ISO format",
},
endTime: {
type: "string",
description: "The end time in ISO format",
},
},
required: ["query"],
additionalProperties: false,
}),
});
const addMemoryTool = tool({
description: "Add information to the user's memory graph",
parameters: jsonSchema({
type: "object",
properties: {
message: {
type: "string",
description: "The content/text to add to memory",
},
},
required: ["message"],
additionalProperties: false,
}),
});
const internalTools = [
"core--progress_update",
"core--search_memory",
"core--add_memory",
];
async function addResources(messages: CoreMessage[], resources: Resource[]) {
const resourcePromises = resources.map(async (resource) => {
// Remove everything before "/api" in the publicURL
if (resource.publicURL) {
const apiIndex = resource.publicURL.indexOf("/api");
if (apiIndex !== -1) {
resource.publicURL = resource.publicURL.substring(apiIndex);
}
}
const response = await axios.get(resource.publicURL, {
responseType: "arraybuffer",
});
if (resource.fileType.startsWith("image/")) {
return {
type: "image",
image: response.data as DataContent,
};
}
return {
type: "file",
data: response.data as DataContent,
mimeType: resource.fileType,
};
});
const content = await Promise.all(resourcePromises);
return [...messages, { role: "user", content } as CoreMessage];
}
function toolToMessage(history: HistoryStep[], messages: CoreMessage[]) {
for (let i = 0; i < history.length; i++) {
const step = history[i];
// Add assistant message with tool calls
if (step.observation && step.skillId) {
messages.push({
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: step.skillId,
toolName: step.skill ?? "",
args:
typeof step.skillInput === "string"
? JSON.parse(step.skillInput)
: step.skillInput,
},
],
});
messages.push({
role: "tool",
content: [
{
type: "tool-result",
toolName: step.skill,
toolCallId: step.skillId,
result: step.observation,
isError: step.isError,
},
],
} as any);
}
// Handle format correction steps (observation exists but no skillId)
else if (step.observation && !step.skillId) {
// Add as a system message for format correction
messages.push({
role: "system",
content: step.observation,
});
}
}
return messages;
}
async function makeNextCall(
executionState: ExecutionState,
TOOLS: ToolSet,
totalCost: TotalCost,
guardLoop: number,
): Promise<LLMOutputInterface> {
const { context, history, previousHistory } = executionState;
const promptInfo = {
USER_MESSAGE: executionState.query,
CONTEXT: context,
USER_MEMORY: executionState.userMemoryContext,
};
let messages: CoreMessage[] = [];
const systemTemplateHandler = Handlebars.compile(REACT_SYSTEM_PROMPT);
let systemPrompt = systemTemplateHandler(promptInfo);
const userTemplateHandler = Handlebars.compile(REACT_USER_PROMPT);
const userPrompt = userTemplateHandler(promptInfo);
// Always start with a system message (this does use tokens but keeps the instructions clear)
messages.push({ role: "system", content: systemPrompt });
// For subsequent queries, include only final responses from previous exchanges if available
if (previousHistory && previousHistory.length > 0) {
messages = [...messages, ...previousHistory];
}
// Add the current user query (much simpler than the full prompt)
messages.push({ role: "user", content: userPrompt });
// Include any steps from the current interaction
if (history.length > 0) {
messages = toolToMessage(history, messages);
}
if (executionState.resources && executionState.resources.length > 0) {
messages = await addResources(messages, executionState.resources);
}
// Get the next action from the LLM
const response = generate(
messages,
guardLoop > 0 && guardLoop % 3 === 0,
(event) => {
const usage = event.usage;
totalCost.inputTokens += usage.promptTokens;
totalCost.outputTokens += usage.completionTokens;
},
TOOLS,
);
return { response };
}
export async function* run(
message: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: Record<string, any>,
previousHistory: CoreMessage[],
mcp: MCP,
stepHistory: HistoryStep[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): AsyncGenerator<AgentMessage, any, any> {
let guardLoop = 0;
let tools = {
"core--progress_update": progressUpdateTool,
"core--search_memory": searchMemoryTool,
"core--add_memory": addMemoryTool,
};
logger.info("Tools have been formed");
let contextText = "";
let resources = [];
if (context) {
// Extract resources and remove from context
resources = context.resources || [];
delete context.resources;
// Process remaining context
contextText = flattenObject(context).join("\n");
}
const executionState: ExecutionState = {
query: message,
context: contextText,
resources,
previousHistory,
history: stepHistory, // Track the full ReAct history
completed: false,
};
const totalCost: TotalCost = { inputTokens: 0, outputTokens: 0, cost: 0 };
try {
while (!executionState.completed && guardLoop < 50) {
logger.info(`Starting the loop: ${guardLoop}`);
const { response: llmResponse } = await makeNextCall(
executionState,
tools,
totalCost,
guardLoop,
);
let toolCallInfo;
const messageState = {
inTag: false,
message: "",
messageEnded: false,
lastSent: "",
};
const questionState = {
inTag: false,
message: "",
messageEnded: false,
lastSent: "",
};
let totalMessage = "";
const toolCalls = [];
// LLM thought response
for await (const chunk of llmResponse) {
if (typeof chunk === "object" && chunk.type === "tool-call") {
toolCallInfo = chunk;
toolCalls.push(chunk);
}
totalMessage += chunk;
if (!messageState.messageEnded) {
yield* processTag(
messageState,
totalMessage,
chunk as string,
"<final_response>",
"</final_response>",
{
start: AgentMessageType.MESSAGE_START,
chunk: AgentMessageType.MESSAGE_CHUNK,
end: AgentMessageType.MESSAGE_END,
},
);
}
if (!questionState.messageEnded) {
yield* processTag(
questionState,
totalMessage,
chunk as string,
"<question_response>",
"</question_response>",
{
start: AgentMessageType.MESSAGE_START,
chunk: AgentMessageType.MESSAGE_CHUNK,
end: AgentMessageType.MESSAGE_END,
},
);
}
}
logger.info(`Cost for thought: ${JSON.stringify(totalCost)}`);
// Replace the error-handling block with this self-correcting implementation
if (
!totalMessage.includes("final_response") &&
!totalMessage.includes("question_response") &&
!toolCallInfo
) {
// Log the issue for debugging
logger.info(
`Invalid response format detected. Attempting to get proper format.`,
);
// Extract the raw content from the invalid response
const rawContent = totalMessage
.replace(/(<[^>]*>|<\/[^>]*>)/g, "")
.trim();
// Create a correction step
const stepRecord: HistoryStep = {
thought: "",
skill: "",
skillId: "",
userMessage: "Sol agent error, retrying \n",
isQuestion: false,
isFinal: false,
tokenCount: totalCost,
skillInput: "",
observation: `Your last response was not in a valid format. You must respond with EXACTLY ONE of the required formats: either a tool call, <question_response> tags, or <final_response> tags. Please reformat your previous response using the correct format:\n\n${rawContent}`,
};
yield Message("", AgentMessageType.MESSAGE_START);
yield Message(
stepRecord.userMessage as string,
AgentMessageType.MESSAGE_CHUNK,
);
yield Message("", AgentMessageType.MESSAGE_END);
// Add this step to the history
yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP);
executionState.history.push(stepRecord);
// Log that we're continuing the loop with a correction request
logger.info(`Added format correction request to history.`);
// Don't mark as completed - let the loop continue
guardLoop++; // Still increment to prevent infinite loops
continue;
}
// Record this step in history
const stepRecord: HistoryStep = {
thought: "",
skill: "",
skillId: "",
userMessage: "",
isQuestion: false,
isFinal: false,
tokenCount: totalCost,
skillInput: "",
};
if (totalMessage && totalMessage.includes("final_response")) {
executionState.completed = true;
stepRecord.isFinal = true;
stepRecord.userMessage = messageState.message;
stepRecord.finalTokenCount = totalCost;
stepRecord.skillStatus = ActionStatusEnum.SUCCESS;
yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP);
executionState.history.push(stepRecord);
break;
}
if (totalMessage && totalMessage.includes("question_response")) {
executionState.completed = true;
stepRecord.isQuestion = true;
stepRecord.userMessage = questionState.message;
stepRecord.finalTokenCount = totalCost;
stepRecord.skillStatus = ActionStatusEnum.QUESTION;
yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP);
executionState.history.push(stepRecord);
break;
}
if (toolCalls && toolCalls.length > 0) {
// Run all tool calls in parallel
for (const toolCallInfo of toolCalls) {
const skillName = toolCallInfo.toolName;
const skillId = toolCallInfo.toolCallId;
const skillInput = toolCallInfo.args;
const toolName = skillName.split("--")[1];
const agent = skillName.split("--")[0];
const stepRecord: HistoryStep = {
agent,
thought: "",
skill: skillName,
skillId,
userMessage: "",
isQuestion: false,
isFinal: false,
tokenCount: totalCost,
skillInput: JSON.stringify(skillInput),
};
if (!internalTools.includes(skillName)) {
const skillMessageToSend = `\n<skill id="${skillId}" name="${toolName}" agent="${agent}"></skill>\n`;
stepRecord.userMessage += skillMessageToSend;
yield Message("", AgentMessageType.MESSAGE_START);
yield Message(skillMessageToSend, AgentMessageType.MESSAGE_CHUNK);
yield Message("", AgentMessageType.MESSAGE_END);
}
let result;
try {
// Log skill execution details
logger.info(`Executing skill: ${skillName}`);
logger.info(`Input parameters: ${JSON.stringify(skillInput)}`);
if (!internalTools.includes(toolName)) {
yield Message(
JSON.stringify({ skillId, status: "start" }),
AgentMessageType.SKILL_START,
);
}
// Handle CORE agent tools
if (agent === "core") {
if (toolName === "progress_update") {
yield Message("", AgentMessageType.MESSAGE_START);
yield Message(
skillInput.message,
AgentMessageType.MESSAGE_CHUNK,
);
stepRecord.userMessage += skillInput.message;
yield Message("", AgentMessageType.MESSAGE_END);
result = "Progress update sent successfully";
} else if (toolName === "search_memory") {
try {
result = await searchMemory(skillInput);
} catch (apiError) {
logger.error("Memory utils calls failed for search_memory", {
apiError,
});
result =
"Memory search failed - please check your memory configuration";
}
} else if (toolName === "add_memory") {
try {
result = await addMemory(skillInput);
} catch (apiError) {
logger.error("Memory utils calls failed for add_memory", {
apiError,
});
result =
"Memory storage failed - please check your memory configuration";
}
}
}
// Handle other MCP tools
else {
result = await mcp.callTool(skillName, skillInput);
yield Message(
JSON.stringify({ result, skillId }),
AgentMessageType.SKILL_CHUNK,
);
}
yield Message(
JSON.stringify({ skillId, status: "end" }),
AgentMessageType.SKILL_END,
);
stepRecord.skillOutput =
typeof result === "object"
? JSON.stringify(result, null, 2)
: result;
stepRecord.observation = stepRecord.skillOutput;
} catch (e) {
console.log(e);
logger.error(e as string);
stepRecord.skillInput = skillInput;
stepRecord.observation = JSON.stringify(e);
stepRecord.isError = true;
}
logger.info(`Skill step: ${JSON.stringify(stepRecord)}`);
yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP);
executionState.history.push(stepRecord);
}
}
guardLoop++;
}
yield Message("Stream ended", AgentMessageType.STREAM_END);
} catch (e) {
logger.error(e as string);
yield Message((e as Error).message, AgentMessageType.ERROR);
yield Message("Stream ended", AgentMessageType.STREAM_END);
}
}

View File

@ -0,0 +1,136 @@
import { ActionStatusEnum } from "@core/types";
import { metadata, task, queue } from "@trigger.dev/sdk";
import { run } from "./chat-utils";
import { MCP } from "../utils/mcp";
import { type HistoryStep } from "../utils/types";
import {
createConversationHistoryForAgent,
deletePersonalAccessToken,
getPreviousExecutionHistory,
init,
type RunChatPayload,
updateConversationHistoryMessage,
updateConversationStatus,
updateExecutionStep,
} from "../utils/utils";
const chatQueue = queue({
name: "chat-queue",
concurrencyLimit: 10,
});
/**
* Main chat task that orchestrates the agent workflow
* Handles conversation context, agent selection, and LLM interactions
*/
export const chat = task({
id: "chat",
maxDuration: 3000,
queue: chatQueue,
init,
run: async (payload: RunChatPayload, { init }) => {
await updateConversationStatus("running", payload.conversationId);
try {
let creditForChat = 0;
const { previousHistory, ...otherData } = payload.context;
// Initialise mcp
const mcp = new MCP();
await mcp.init();
// Prepare context with additional metadata
const context = {
// Currently this is assuming we only have one page in context
context: {
...(otherData.page && otherData.page.length > 0
? { page: otherData.page[0] }
: {}),
},
workpsaceId: init?.conversation.workspaceId,
resources: otherData.resources,
todayDate: new Date().toISOString(),
};
// Extract user's goal from conversation history
const message = init?.conversationHistory?.message;
// Retrieve execution history from previous interactions
const previousExecutionHistory = getPreviousExecutionHistory(
previousHistory ?? [],
);
let agentUserMessage = "";
let agentConversationHistory;
let stepHistory: HistoryStep[] = [];
// Prepare conversation history in agent-compatible format
agentConversationHistory = await createConversationHistoryForAgent(
payload.conversationId,
);
const llmResponse = run(
message as string,
context,
previousExecutionHistory,
mcp,
stepHistory,
);
const stream = await metadata.stream("messages", llmResponse);
let conversationStatus = "success";
for await (const step of stream) {
if (step.type === "STEP") {
creditForChat += 1;
const stepDetails = JSON.parse(step.message as string);
if (stepDetails.skillStatus === ActionStatusEnum.TOOL_REQUEST) {
conversationStatus = "need_approval";
}
if (stepDetails.skillStatus === ActionStatusEnum.QUESTION) {
conversationStatus = "need_attention";
}
await updateExecutionStep(
{ ...stepDetails },
agentConversationHistory.id,
);
agentUserMessage += stepDetails.userMessage;
await updateConversationHistoryMessage(
agentUserMessage,
agentConversationHistory.id,
);
} else if (step.type === "STREAM_END") {
break;
}
}
await updateConversationStatus(
conversationStatus,
payload.conversationId,
);
// await addToMemory(
// init.conversation.id,
// message,
// agentUserMessage,
// init.preferences,
// init.userName,
// );
if (init?.tokenId) {
await deletePersonalAccessToken(init.tokenId);
}
} catch (e) {
await updateConversationStatus("failed", payload.conversationId);
if (init?.tokenId) {
await deletePersonalAccessToken(init.tokenId);
}
throw new Error(e as string);
}
},
});

View File

@ -0,0 +1,50 @@
import { logger } from "@trigger.dev/sdk/v3";
import axios from "axios";
// Memory API functions using axios interceptor
export interface SearchMemoryParams {
query: string;
validAt?: string;
startTime?: string;
endTime?: string;
}
export interface AddMemoryParams {
message: string;
referenceTime?: string;
source?: string;
spaceId?: string;
sessionId?: string;
metadata?: any;
}
export const searchMemory = async (params: SearchMemoryParams) => {
try {
const response = await axios.post("https://core::memory/search", params);
return response.data;
} catch (error) {
logger.error("Memory search failed", { error, params });
return { error: "Memory search failed" };
}
};
export const addMemory = async (params: AddMemoryParams) => {
try {
// Set defaults for required fields
const memoryInput = {
...params,
episodeBody: params.message,
referenceTime: params.referenceTime || new Date().toISOString(),
source: params.source || "CORE",
};
const response = await axios.post(
"https://core::memory/ingest",
memoryInput,
);
return response.data;
} catch (error) {
logger.error("Memory storage failed", { error, params });
return { error: "Memory storage failed" };
}
};

View File

@ -0,0 +1,131 @@
export const REACT_SYSTEM_PROMPT = `
You are a helpful AI assistant with access to user memory. Your primary capabilities are:
1. **Memory-First Approach**: Always check user memory first to understand context and previous interactions
2. **Memory Management**: Help users store, retrieve, and organize information in their memory
3. **Contextual Assistance**: Use memory to provide personalized and contextual responses
<context>
{{CONTEXT}}
</context>
<memory>
- Always check memory FIRST using core--search_memory before any other actions
- Consider this your highest priority for EVERY interaction - as essential as breathing
- Make memory checking your first tool call before any other operations
QUERY FORMATION:
- Write specific factual statements as queries (e.g., "user email address" not "what is the user's email?")
- Create multiple targeted memory queries for complex requests
KEY QUERY AREAS:
- Personal context: user name, location, identity, work context
- Project context: repositories, codebases, current work, team members
- Task context: recent tasks, ongoing projects, deadlines, priorities
- Integration context: GitHub repos, Slack channels, Linear projects, connected services
- Communication patterns: email preferences, notification settings, workflow automation
- Technical context: coding languages, frameworks, development environment
- Collaboration context: team members, project stakeholders, meeting patterns
- Preferences: likes, dislikes, communication style, tool preferences
- History: previous discussions, past requests, completed work, recurring issues
- Automation rules: user-defined workflows, triggers, automation preferences
MEMORY USAGE:
- Execute multiple memory queries in parallel rather than sequentially
- Batch related memory queries when possible
- Prioritize recent information over older memories
- Create comprehensive context-aware queries based on user message/activity content
- Extract and query SEMANTIC CONTENT, not just structural metadata
- Parse titles, descriptions, and content for actual subject matter keywords
- Search internal SOL tasks/conversations that may relate to the same topics
- Query ALL relatable concepts, not just direct keywords or IDs
- Search for similar past situations, patterns, and related work
- Include synonyms, related terms, and contextual concepts in queries
- Query user's historical approach to similar requests or activities
- Search for connected projects, tasks, conversations, and collaborations
- Retrieve workflow patterns and past decision-making context
- Query broader domain context beyond immediate request scope
- Remember: SOL tracks work that external tools don't - search internal content thoroughly
- Blend memory insights naturally into responses
- Verify you've checked relevant memory before finalizing ANY response
If memory access is unavailable, rely only on the current conversation or ask user
</memory>
<tool_calling>
You have tools at your disposal to assist users:
CORE PRINCIPLES:
- Use tools only when necessary for the task at hand
- Always check memory FIRST before making other tool calls
- Execute multiple operations in parallel whenever possible
- Use sequential calls only when output of one is required for input of another
PARAMETER HANDLING:
- Follow tool schemas exactly with all required parameters
- Only use values that are:
Explicitly provided by the user (use EXACTLY as given)
Reasonably inferred from context
Retrieved from memory or prior tool calls
- Never make up values for required parameters
- Omit optional parameters unless clearly needed
- Analyze user's descriptive terms for parameter clues
TOOL SELECTION:
- Never call tools not provided in this conversation
- Skip tool calls for general questions you can answer directly
- For identical operations on multiple items, use parallel tool calls
- Default to parallel execution (3-5× faster than sequential calls)
- You can always access external service tools by loading them with load_mcp first
TOOL MENTION HANDLING:
When user message contains <mention data-id="tool_name" data-label="tool"></mention>:
- Extract tool_name from data-id attribute
- First check if it's a built-in tool; if not, check EXTERNAL SERVICES TOOLS
- If available: Load it with load_mcp and focus on addressing the request with this tool
- If unavailable: Inform user and suggest alternatives if possible
- For multiple tool mentions: Load all applicable tools in a single load_mcp call
ERROR HANDLING:
- If a tool returns an error, try fixing parameters before retrying
- If you can't resolve an error, explain the issue to the user
- Consider alternative tools when primary tools are unavailable
</tool_calling>
<communication>
Use EXACTLY ONE of these formats for all user-facing communication:
PROGRESS UPDATES - During processing:
- Use the core--progress_update tool to keep users informed
- Update users about what you're discovering or doing next
- Keep messages clear and user-friendly
- Avoid technical jargon
QUESTIONS - When you need information:
<question_response>
<p>[Your question with HTML formatting]</p>
</question_response>
- Ask questions only when you cannot find information through memory or tools
- Be specific about what you need to know
- Provide context for why you're asking
FINAL ANSWERS - When completing tasks:
<final_response>
<p>[Your answer with HTML formatting]</p>
</final_response>
CRITICAL:
- Use ONE format per turn
- Apply proper HTML formatting (<h1>, <h2>, <p>, <ul>, <li>, etc.)
- Never mix communication formats
- Keep responses clear and helpful
</communication>
`;
export const REACT_USER_PROMPT = `
Here is the user message:
<user_message>
{{USER_MESSAGE}}
</user_message>
`;

View File

@ -0,0 +1,264 @@
import fs from "fs";
import path from "node:path";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { logger } from "@trigger.dev/sdk/v3";
import {
type CoreMessage,
type LanguageModelV1,
streamText,
type ToolSet,
} from "ai";
import { createOllama } from "ollama-ai-provider";
import { type AgentMessageType, Message } from "./types";
interface State {
inTag: boolean;
messageEnded: boolean;
message: string;
lastSent: string;
}
export interface ExecutionState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agentFlow: any;
userMessage: string;
message: string;
}
export async function* processTag(
state: State,
totalMessage: string,
chunk: string,
startTag: string,
endTag: string,
states: { start: string; chunk: string; end: string },
extraParams: Record<string, string> = {},
) {
let comingFromStart = false;
if (!state.messageEnded) {
if (!state.inTag) {
const startIndex = totalMessage.indexOf(startTag);
if (startIndex !== -1) {
state.inTag = true;
// Send MESSAGE_START when we first enter the tag
yield Message("", states.start as AgentMessageType, extraParams);
const chunkToSend = totalMessage.slice(startIndex + startTag.length);
state.message += chunkToSend;
comingFromStart = true;
}
}
if (state.inTag) {
// Check if chunk contains end tag
const hasEndTag = chunk.includes(endTag);
const hasStartTag = chunk.includes(startTag);
const hasClosingTag = chunk.includes("</");
if (hasClosingTag && !hasStartTag && !hasEndTag) {
// If chunk only has </ but not the full end tag, accumulate it
state.message += chunk;
} else if (hasEndTag || (!hasEndTag && !hasClosingTag)) {
let currentMessage = comingFromStart
? state.message
: state.message + chunk;
const endIndex = currentMessage.indexOf(endTag);
if (endIndex !== -1) {
// For the final chunk before the end tag
currentMessage = currentMessage.slice(0, endIndex).trim();
const messageToSend = currentMessage.slice(
currentMessage.indexOf(state.lastSent) + state.lastSent.length,
);
if (messageToSend) {
yield Message(
messageToSend,
states.chunk as AgentMessageType,
extraParams,
);
}
// Send MESSAGE_END when we reach the end tag
yield Message("", states.end as AgentMessageType, extraParams);
state.message = currentMessage;
state.messageEnded = true;
} else {
const diff = currentMessage.slice(
currentMessage.indexOf(state.lastSent) + state.lastSent.length,
);
// For chunks in between start and end
const messageToSend = comingFromStart ? state.message : diff;
if (messageToSend) {
state.lastSent = messageToSend;
yield Message(
messageToSend,
states.chunk as AgentMessageType,
extraParams,
);
}
}
state.message = currentMessage;
state.lastSent = state.message;
} else {
state.message += chunk;
}
}
}
}
export async function* generate(
messages: CoreMessage[],
isProgressUpdate: boolean = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFinish?: (event: any) => void,
tools?: ToolSet,
system?: string,
model?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): AsyncGenerator<
| string
| {
type: string;
toolName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args?: any;
toolCallId?: string;
message?: string;
}
> {
// Check for API keys
const anthropicKey = process.env.ANTHROPIC_API_KEY;
const googleKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
const openaiKey = process.env.OPENAI_API_KEY;
let ollamaUrl = process.env.OLLAMA_URL;
model = model || process.env.MODEL;
let modelInstance;
let modelTemperature = Number(process.env.MODEL_TEMPERATURE) || 1;
ollamaUrl = undefined;
// First check if Ollama URL exists and use Ollama
if (ollamaUrl) {
const ollama = createOllama({
baseURL: ollamaUrl,
});
modelInstance = ollama(model || "llama2"); // Default to llama2 if no model specified
} else {
// If no Ollama, check other models
switch (model) {
case "claude-3-7-sonnet-20250219":
case "claude-3-opus-20240229":
case "claude-3-5-haiku-20241022":
if (!anthropicKey) {
throw new Error("No Anthropic API key found. Set ANTHROPIC_API_KEY");
}
modelInstance = anthropic(model);
modelTemperature = 0.5;
break;
case "gemini-2.5-flash-preview-04-17":
case "gemini-2.5-pro-preview-03-25":
case "gemini-2.0-flash":
case "gemini-2.0-flash-lite":
if (!googleKey) {
throw new Error("No Google API key found. Set GOOGLE_API_KEY");
}
modelInstance = google(model);
break;
case "gpt-4.1-2025-04-14":
case "gpt-4.1-mini-2025-04-14":
case "gpt-4.1-nano-2025-04-14":
if (!openaiKey) {
throw new Error("No OpenAI API key found. Set OPENAI_API_KEY");
}
modelInstance = openai(model);
break;
default:
break;
}
}
logger.info("starting stream");
// Try Anthropic next if key exists
if (modelInstance) {
try {
const { textStream, fullStream } = streamText({
model: modelInstance as LanguageModelV1,
messages,
temperature: modelTemperature,
maxSteps: 10,
tools,
...(isProgressUpdate
? { toolChoice: { type: "tool", toolName: "core--progress_update" } }
: {}),
toolCallStreaming: true,
onFinish,
...(system ? { system } : {}),
});
for await (const chunk of textStream) {
yield chunk;
}
for await (const fullChunk of fullStream) {
if (fullChunk.type === "tool-call") {
yield {
type: "tool-call",
toolName: fullChunk.toolName,
toolCallId: fullChunk.toolCallId,
args: fullChunk.args,
};
}
if (fullChunk.type === "error") {
// Log the error to a file
const errorLogsDir = path.join(__dirname, "../../../../logs/errors");
// Ensure the directory exists
try {
if (!fs.existsSync(errorLogsDir)) {
fs.mkdirSync(errorLogsDir, { recursive: true });
}
// Create a timestamped error log file
const timestamp = new Date().toISOString().replace(/:/g, "-");
const errorLogPath = path.join(
errorLogsDir,
`llm-error-${timestamp}.json`,
);
// Write the error to the file
fs.writeFileSync(
errorLogPath,
JSON.stringify({
timestamp: new Date().toISOString(),
error: fullChunk.error,
}),
);
logger.error(`LLM error logged to ${errorLogPath}`);
} catch (err) {
logger.error(`Failed to log LLM error: ${err}`);
}
}
}
return;
} catch (e) {
console.log(e);
logger.error(e as string);
}
}
throw new Error("No valid LLM configuration found");
}

View File

@ -0,0 +1,46 @@
export interface AgentStep {
agent: string;
goal: string;
reasoning: string;
}
export enum AgentMessageType {
STREAM_START = 'STREAM_START',
STREAM_END = 'STREAM_END',
// Used in ReACT based prompting
THOUGHT_START = 'THOUGHT_START',
THOUGHT_CHUNK = 'THOUGHT_CHUNK',
THOUGHT_END = 'THOUGHT_END',
// Message types
MESSAGE_START = 'MESSAGE_START',
MESSAGE_CHUNK = 'MESSAGE_CHUNK',
MESSAGE_END = 'MESSAGE_END',
// This is used to return action input
SKILL_START = 'SKILL_START',
SKILL_CHUNK = 'SKILL_CHUNK',
SKILL_END = 'SKILL_END',
STEP = 'STEP',
ERROR = 'ERROR',
}
export interface AgentMessage {
message?: string;
type: AgentMessageType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: Record<string, any>;
}
export const Message = (
message: string,
type: AgentMessageType,
extraParams: Record<string, string> = {},
): AgentMessage => {
// For all message types, we use the message field
// The type field differentiates how the message should be interpreted
// For STEP and SKILL types, the message can contain JSON data as a string
return { message, type, metadata: extraParams };
};

View File

@ -0,0 +1,62 @@
import { PrismaClient } from "@prisma/client";
import { LLMMappings } from "@core/types";
import { logger, task } from "@trigger.dev/sdk/v3";
import { generate } from "../chat/stream-utils";
import { conversationTitlePrompt } from "./prompt";
const prisma = new PrismaClient();
export const createConversationTitle = task({
id: "create-conversation-title",
run: async (payload: { conversationId: string; message: string }) => {
let conversationTitleResponse = "";
const gen = generate(
[
{
role: "user",
content: conversationTitlePrompt.replace(
"{{message}}",
payload.message,
),
},
],
false,
() => {},
undefined,
"",
LLMMappings.CLAUDESONNET,
);
for await (const chunk of gen) {
if (typeof chunk === "string") {
conversationTitleResponse += chunk;
} else if (chunk && typeof chunk === "object" && chunk.message) {
conversationTitleResponse += chunk.message;
}
}
const outputMatch = conversationTitleResponse.match(
/<output>(.*?)<\/output>/s,
);
logger.info(`Conversation title data: ${JSON.stringify(outputMatch)}`);
if (!outputMatch) {
logger.error("No output found in recurrence response");
throw new Error("Invalid response format from AI");
}
const jsonStr = outputMatch[1].trim();
const conversationTitleData = JSON.parse(jsonStr);
if (conversationTitleData) {
await prisma.conversation.update({
where: {
id: payload.conversationId,
},
data: {
title: conversationTitleData.title,
},
});
}
},
});

View File

@ -0,0 +1,28 @@
export const conversationTitlePrompt = `You are an AI assistant specialized in generating concise and informative conversation titles. Your task is to analyze the given message and context to create an appropriate title.
Here is the message:
<message>
{{message}}
</message>
Please follow these steps:
- Extract the core topic/intent from the message
- Create a clear, concise title
- Focus on the main subject or action
- Avoid unnecessary words
- Maximum length: 60 characters
Before providing output, analyze in <title_analysis> tags:
- Key elements from message
- Main topic/action
- Relevant actors/context
- Your title formation process
Provide final output in this format:
<output>
{
"title": "Your generated title"
}
</output>
If message is empty or contains no meaningful content, return {"title": "New Conversation"}`;

View File

@ -0,0 +1,38 @@
// import { PrismaClient } from "@prisma/client";
// import { IntegrationPayloadEventType } from "@core/types";
// import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
// import { integrationRun } from "./integration-run";
// const prisma = new PrismaClient();
// export const integrationRunSchedule = schedules.task({
// id: "integration-run-schedule",
// run: async (payload) => {
// const { externalId } = payload;
// const integrationAccount = await prisma.integrationAccount.findUnique({
// where: { id: externalId },
// include: {
// integrationDefinition: true,
// workspace: true,
// },
// });
// if (!integrationAccount) {
// const deletedSchedule = await schedules.del(externalId);
// logger.info("Deleting schedule as integration account is not there");
// return deletedSchedule;
// }
// const pat = await prisma.personalAccessToken.findFirst({
// where: { userId: integrationAccount.workspace.userId, name: "default" },
// });
// return await tasks.trigger<typeof integrationRun>("integration-run", {
// event: IntegrationPayloadEventType.SCHEDULED_SYNC,
// pat: pat.token,
// integrationAccount,
// integrationDefinition: integrationAccount.integrationDefinition,
// });
// },
// });

View File

@ -0,0 +1,87 @@
// import createLoadRemoteModule, {
// createRequires,
// } from "@paciolan/remote-module-loader";
// import { logger, task } from "@trigger.dev/sdk/v3";
// import axios from "axios";
// const fetcher = async (url: string) => {
// // Handle remote URLs with axios
// const response = await axios.get(url);
// return response.data;
// };
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const loadRemoteModule = async (requires: any) =>
// createLoadRemoteModule({ fetcher, requires });
// function createAxiosInstance(token: string) {
// const instance = axios.create();
// instance.interceptors.request.use((config) => {
// // Check if URL starts with /api and doesn't have a full host
// if (config.url?.startsWith("/api")) {
// config.url = `${process.env.BACKEND_HOST}${config.url.replace("/api/", "/")}`;
// }
// if (
// config.url.includes(process.env.FRONTEND_HOST) ||
// config.url.includes(process.env.BACKEND_HOST)
// ) {
// config.headers.Authorization = `Bearer ${token}`;
// }
// return config;
// });
// return instance;
// }
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const getRequires = (axios: any) => createRequires({ axios });
// export const integrationRun = task({
// id: "integration-run",
// run: async ({
// pat,
// eventBody,
// integrationAccount,
// integrationDefinition,
// event,
// }: {
// pat: string;
// // This is the event you want to pass to the integration
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// event: any;
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eventBody?: any;
// integrationDefinition: IntegrationDefinition;
// integrationAccount?: IntegrationAccount;
// }) => {
// const remoteModuleLoad = await loadRemoteModule(
// getRequires(createAxiosInstance(pat)),
// );
// logger.info(
// `${integrationDefinition.url}/${integrationDefinition.version}/backend/index.js`,
// );
// const integrationFunction = await remoteModuleLoad(
// `${integrationDefinition.url}/${integrationDefinition.version}/backend/index.js`,
// );
// // const integrationFunction = await remoteModuleLoad(
// // `${integrationDefinition.url}`,
// // );
// return await integrationFunction.run({
// integrationAccount,
// integrationDefinition,
// event,
// eventBody: {
// ...(eventBody ? eventBody : {}),
// },
// });
// },
// });

View File

@ -0,0 +1,64 @@
// import { PrismaClient } from "@prisma/client";
// import { logger, schedules, task } from "@trigger.dev/sdk/v3";
// import { integrationRunSchedule } from "./integration-run-schedule";
// const prisma = new PrismaClient();
// export const scheduler = task({
// id: "scheduler",
// run: async (payload: { integrationAccountId: string }) => {
// const { integrationAccountId } = payload;
// const integrationAccount = await prisma.integrationAccount.findUnique({
// where: { id: integrationAccountId, deleted: null },
// include: {
// integrationDefinition: true,
// workspace: true,
// },
// });
// if (!integrationAccount) {
// logger.error("Integration account not found");
// return null;
// }
// if (!integrationAccount.workspace) {
// return null;
// }
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const spec = integrationAccount.integrationDefinition.spec as any;
// if (spec.schedule && spec.schedule.frequency) {
// const createdSchedule = await schedules.create({
// // The id of the scheduled task you want to attach to.
// task: integrationRunSchedule.id,
// // The schedule in cron format.
// cron: spec.schedule.frequency,
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// timezone: (integrationAccount.workspace.preferences as any).timezone,
// // this is required, it prevents you from creating duplicate schedules. It will update the schedule if it already exists.
// deduplicationKey: integrationAccount.id,
// externalId: integrationAccount.id,
// });
// await prisma.integrationAccount.update({
// where: {
// id: integrationAccount.id,
// },
// data: {
// settings: {
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// ...(integrationAccount.settings as any),
// scheduleId: createdSchedule.id,
// },
// },
// });
// return createdSchedule;
// }
// return "No schedule for this task";
// },
// });

View File

@ -0,0 +1,151 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { logger } from "@trigger.dev/sdk/v3";
import { jsonSchema, tool, type ToolSet } from "ai";
import { type MCPTool } from "./types";
export class MCP {
private Client: any;
private clients: Record<string, any> = {};
private StdioTransport: any;
constructor() {}
public async init() {
this.Client = await MCP.importClient();
this.StdioTransport = await MCP.importStdioTransport();
}
private static async importClient() {
const { Client } = await import(
"@modelcontextprotocol/sdk/client/index.js"
);
return Client;
}
async load(agents: string[], mcpConfig: any) {
await Promise.all(
agents.map(async (agent) => {
const mcp = mcpConfig.mcpServers[agent];
return await this.connectToServer(agent, mcp.command, mcp.args, {
...mcp.env,
DATABASE_URL: mcp.env?.DATABASE_URL ?? "",
});
}),
);
}
private static async importStdioTransport() {
const { StdioClientTransport } = await import("./stdio");
return StdioClientTransport;
}
async allTools(): Promise<ToolSet> {
const clientEntries = Object.entries(this.clients);
// Fetch all tools in parallel
const toolsArrays = await Promise.all(
clientEntries.map(async ([clientKey, client]) => {
try {
const { tools } = await client.listTools();
return tools.map(({ name, description, inputSchema }: any) => [
`${clientKey}--${name}`,
tool({
description,
parameters: jsonSchema(inputSchema),
}),
]);
} catch (error) {
logger.error(`Error fetching tools for ${clientKey}:`, { error });
return [];
}
}),
);
// Flatten and convert to object
return Object.fromEntries(toolsArrays.flat());
}
async tools(): Promise<MCPTool[]> {
const allTools: MCPTool[] = [];
for (const clientKey in this.clients) {
const client = this.clients[clientKey];
const { tools: clientTools } = await client.listTools();
for (const tool of clientTools) {
// Add client prefix to tool name
tool.name = `${clientKey}--${tool.name}`;
allTools.push(tool);
}
}
return allTools;
}
async getTool(name: string) {
try {
const clientKey = name.split("--")[0];
const toolName = name.split("--")[1];
const client = this.clients[clientKey];
const { tools: clientTools } = await client.listTools();
const clientTool = clientTools.find((to: any) => to.name === toolName);
return JSON.stringify(clientTool);
} catch (e) {
logger.error((e as string) ?? "Getting tool failed");
throw new Error("Getting tool failed");
}
}
async callTool(name: string, parameters: any) {
const clientKey = name.split("--")[0];
const toolName = name.split("--")[1];
const client = this.clients[clientKey];
const response = await client.callTool({
name: toolName,
arguments: parameters,
});
return response;
}
async connectToServer(
name: string,
command: string,
args: string[],
env: any,
) {
try {
const client = new this.Client(
{
name,
version: "1.0.0",
},
{
capabilities: {},
},
);
// Conf
// igure the transport for MCP server
const transport = new this.StdioTransport({
command,
args,
env,
});
// Connect to the MCP server
await client.connect(transport, { timeout: 60 * 1000 * 5 });
this.clients[name] = client;
logger.info(`Connected to ${name} MCP server`);
} catch (e) {
logger.error(`Failed to connect to ${name} MCP server: `, { e });
throw e;
}
}
}

View File

@ -0,0 +1,256 @@
import { type ChildProcess, type IOType } from "node:child_process";
import process from "node:process";
import { type Stream } from "node:stream";
import { type Transport } from "@modelcontextprotocol/sdk/shared/transport";
import {
type JSONRPCMessage,
JSONRPCMessageSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execa } from "execa";
/**
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
*/
export class ReadBuffer {
private _buffer?: Buffer;
append(chunk: Buffer): void {
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
}
readMessage(): JSONRPCMessage | null {
if (!this._buffer) {
return null;
}
const index = this._buffer.indexOf("\n");
if (index === -1) {
return null;
}
const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
this._buffer = this._buffer.subarray(index + 1);
return deserializeMessage(line);
}
clear(): void {
this._buffer = undefined;
}
}
export function deserializeMessage(line: string): JSONRPCMessage {
return JSONRPCMessageSchema.parse(JSON.parse(line));
}
export function serializeMessage(message: JSONRPCMessage): string {
return `${JSON.stringify(message)}\n`;
}
export interface StdioServerParameters {
/**
* The executable to run to start the server.
*/
command: string;
/**
* Command line arguments to pass to the executable.
*/
args?: string[];
/**
* The environment to use when spawning the process.
*
* If not specified, the result of getDefaultEnvironment() will be used.
*/
env?: Record<string, string>;
/**
* How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`.
*
* The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr.
*/
stderr?: IOType | Stream | number;
/**
* The working directory to use when spawning the process.
*
* If not specified, the current working directory will be inherited.
*/
cwd?: string;
}
/**
* Environment variables to inherit by default, if an environment is not explicitly given.
*/
export const DEFAULT_INHERITED_ENV_VARS =
process.platform === "win32"
? [
"APPDATA",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATH",
"PROCESSOR_ARCHITECTURE",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"USERNAME",
"USERPROFILE",
]
: /* list inspired by the default env inheritance of sudo */
["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"];
/**
* Returns a default environment object including only environment variables deemed safe to inherit.
*/
export function getDefaultEnvironment(): Record<string, string> {
const env: Record<string, string> = {};
for (const key of DEFAULT_INHERITED_ENV_VARS) {
const value = process.env[key];
if (value === undefined) {
continue;
}
if (value.startsWith("()")) {
// Skip functions, which are a security risk.
continue;
}
env[key] = value;
}
return env;
}
/**
* Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout.
*
* This transport is only available in Node.js environments.
*/
export class StdioClientTransport implements Transport {
private _process?: ChildProcess;
private _abortController: AbortController = new AbortController();
private _readBuffer: ReadBuffer = new ReadBuffer();
private _serverParams: StdioServerParameters;
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(server: StdioServerParameters) {
this._serverParams = server;
}
/**
* Starts the server process and prepares to communicate with it.
*/
async start(): Promise<void> {
if (this._process) {
throw new Error(
"StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.",
);
}
return new Promise((resolve, reject) => {
this._process = execa(
this._serverParams.command,
this._serverParams.args ?? [],
{
env: this._serverParams.env ?? getDefaultEnvironment(),
stderr: "inherit",
shell: "/bin/sh",
windowsHide: process.platform === "win32" && isElectron(),
cwd: this._serverParams.cwd,
cancelSignal: this._abortController.signal,
stdin: "pipe",
stdout: "pipe",
},
);
this._process.on("error", (error) => {
if (error.name === "AbortError") {
// Expected when close() is called.
this.onclose?.();
return;
}
reject(error);
this.onerror?.(error);
});
this._process.on("spawn", () => {
resolve();
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
this._process.on("close", (_code) => {
this._process = undefined;
this.onclose?.();
});
this._process.stdin?.on("error", (error) => {
this.onerror?.(error);
});
this._process.stdout?.on("data", (chunk) => {
this._readBuffer.append(chunk);
this.processReadBuffer();
});
this._process.stdout?.on("error", (error) => {
this.onerror?.(error);
});
});
}
/**
* The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped".
*
* This is only available after the process has been started.
*/
get stderr(): Stream | null {
return this._process?.stderr ?? null;
}
private processReadBuffer() {
while (true) {
try {
const message = this._readBuffer.readMessage();
if (message === null) {
break;
}
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error as Error);
}
}
}
async close(): Promise<void> {
this._abortController.abort();
this._process = undefined;
this._readBuffer.clear();
}
send(message: JSONRPCMessage): Promise<void> {
return new Promise((resolve) => {
if (!this._process?.stdin) {
throw new Error("Not connected");
}
const json = serializeMessage(message);
if (this._process.stdin.write(json)) {
resolve();
} else {
this._process.stdin.once("drain", resolve);
}
});
}
}
function isElectron() {
return "type" in process;
}

View File

@ -0,0 +1,123 @@
import { type ActionStatusEnum } from "@core/types";
import { type CoreMessage } from "ai";
// Define types for the MCP tool schema
export interface MCPTool {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, SchemaProperty>;
required?: string[];
additionalProperties: boolean;
$schema: string;
};
}
// Vercel AI SDK Tool Types
export type VercelAITools = Record<
string,
{
type: "function";
description: string;
parameters: {
type: "object";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
properties: Record<string, any>;
required?: string[];
};
}
>;
export type SchemaProperty =
| {
type: string | string[];
minimum?: number;
maximum?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default?: any;
minLength?: number;
pattern?: string;
enum?: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: any;
properties?: Record<string, SchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}
| {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
anyOf: any[];
};
export interface Resource {
id?: string;
size?: number;
fileType: string;
publicURL: string;
originalName?: string;
}
export interface ExecutionState {
query: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context?: string;
resources: Resource[];
previousHistory?: CoreMessage[];
history: HistoryStep[];
userMemoryContext?: string;
automationContext?: string;
completed: boolean;
}
export interface TokenCount {
inputTokens: number;
outputToken: number;
}
export interface TotalCost {
inputTokens: number;
outputTokens: number;
cost: number;
}
export interface HistoryStep {
agent?: string;
// The agent's reasoning process for this step
thought?: string;
// Indicates if this step contains a question for the user
isQuestion?: boolean;
// Indicates if this is the final response in the conversation
isFinal?: boolean;
isError?: boolean;
// The name of the skill/tool being used in this step
skill?: string;
skillId?: string;
skillInput?: string;
skillOutput?: string;
skillStatus?: ActionStatusEnum;
// This is when the action has run and the output will be put here
observation?: string;
// This is what the user will read
userMessage?: string;
// If the agent has run completely
completed?: boolean;
// Token count
tokenCount: TotalCost;
finalTokenCount?: TotalCost;
}
export interface GenerateResponse {
text: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
toolCalls: any[];
}

View File

@ -0,0 +1,544 @@
import {
type Activity,
type Conversation,
type ConversationHistory,
type IntegrationDefinitionV2,
type Prisma,
PrismaClient,
UserType,
type Workspace,
} from "@prisma/client";
import { logger } from "@trigger.dev/sdk/v3";
import { type CoreMessage } from "ai";
import { type HistoryStep } from "./types";
import axios from "axios";
import nodeCrypto from "node:crypto";
import { customAlphabet, nanoid } from "nanoid";
const prisma = new PrismaClient();
// Token generation utilities
const tokenValueLength = 40;
const tokenGenerator = customAlphabet(
"123456789abcdefghijkmnopqrstuvwxyz",
tokenValueLength,
);
const tokenPrefix = "rc_pat_";
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
};
// Helper functions for token management
function createToken() {
return `${tokenPrefix}${tokenGenerator()}`;
}
function obfuscateToken(token: string) {
const withoutPrefix = token.replace(tokenPrefix, "");
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
return `${tokenPrefix}${obfuscated}`;
}
function encryptToken(value: string) {
const encryptionKey = process.env.ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error("ENCRYPTION_KEY environment variable is required");
}
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
function hashToken(token: string): string {
const hash = nodeCrypto.createHash("sha256");
hash.update(token);
return hash.digest("hex");
}
export async function getOrCreatePersonalAccessToken({
name,
userId,
}: CreatePersonalAccessTokenOptions) {
// Try to find an existing, non-revoked token
const existing = await prisma.personalAccessToken.findFirst({
where: {
name,
userId,
revokedAt: null,
},
});
if (existing) {
// Do not return the unencrypted token if it already exists
return {
id: existing.id,
name: existing.name,
userId: existing.userId,
obfuscatedToken: existing.obfuscatedToken,
// token is not returned
};
}
// Create a new token
const token = createToken();
const encryptedToken = encryptToken(token);
const personalAccessToken = await prisma.personalAccessToken.create({
data: {
name,
userId,
encryptedToken,
obfuscatedToken: obfuscateToken(token),
hashedToken: hashToken(token),
},
});
return {
id: personalAccessToken.id,
name,
userId,
token,
obfuscatedToken: personalAccessToken.obfuscatedToken,
};
}
export interface InitChatPayload {
conversationId: string;
conversationHistoryId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any;
pat: string;
}
export class Preferences {
timezone?: string;
// Memory details
memory_host?: string;
memory_api_key?: string;
}
export interface RunChatPayload {
conversationId: string;
conversationHistoryId: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any;
conversation: Conversation;
conversationHistory: ConversationHistory;
pat: string;
isContinuation?: boolean;
}
export const init = async ({ payload }: { payload: InitChatPayload }) => {
logger.info("Loading init");
const conversationHistory = await prisma.conversationHistory.findUnique({
where: { id: payload.conversationHistoryId },
include: { conversation: true },
});
const conversation = conversationHistory?.conversation as Conversation;
const workspace = await prisma.workspace.findUnique({
where: { id: conversation.workspaceId as string },
});
if (!workspace) {
return { conversation, conversationHistory };
}
const randomKeyName = `chat_${nanoid(10)}`;
const pat = await getOrCreatePersonalAccessToken({
name: randomKeyName,
userId: workspace.userId as string,
});
const user = await prisma.user.findFirst({
where: { id: workspace.userId as string },
});
const integrationAccounts = await prisma.integrationAccount.findMany({
where: {
workspaceId: workspace.id,
},
include: { integrationDefinition: true },
});
// Set up axios interceptor for memory operations
axios.interceptors.request.use((config) => {
if (config.url?.startsWith("https://core::memory")) {
// Handle both search and ingest endpoints
if (config.url.includes("/search")) {
config.url = `${process.env.API_BASE_URL}/search`;
} else if (config.url.includes("/ingest")) {
config.url = `${process.env.API_BASE_URL}/ingest`;
}
config.headers.Authorization = `Bearer ${pat.token}`;
}
return config;
});
// Create MCP server configurations for each integration account
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const integrationMCPServers: Record<string, any> = {};
for (const account of integrationAccounts) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spec = account.integrationDefinition?.spec as any;
if (spec.mcp) {
const mcpSpec = spec.mcp;
const configuredMCP = { ...mcpSpec };
// Replace config placeholders in environment variables
if (configuredMCP.env) {
for (const [key, value] of Object.entries(configuredMCP.env)) {
if (typeof value === "string" && value.includes("${config:")) {
// Extract the config key from the placeholder
const configKey = value.match(/\$\{config:(.*?)\}/)?.[1];
if (
configKey &&
account.integrationConfiguration &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationConfiguration as any)[configKey]
) {
configuredMCP.env[key] = value.replace(
`\${config:${configKey}}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationConfiguration as any)[configKey],
);
}
}
if (
typeof value === "string" &&
value.includes("${integrationConfig:")
) {
// Extract the config key from the placeholder
const configKey = value.match(
/\$\{integrationConfig:(.*?)\}/,
)?.[1];
if (
configKey &&
account.integrationDefinition.config &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationDefinition.config as any)[configKey]
) {
configuredMCP.env[key] = value.replace(
`\${integrationConfig:${configKey}}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(account.integrationDefinition.config as any)[configKey],
);
}
}
}
}
// Add to the MCP servers collection
integrationMCPServers[account.integrationDefinition.slug] =
configuredMCP;
}
} catch (error) {
logger.error(
`Failed to configure MCP for ${account.integrationDefinition?.slug}:`,
{ error },
);
}
}
return {
conversation,
conversationHistory,
tokenId: pat.id,
token: pat.token,
userId: user?.id,
userName: user?.name,
};
};
export const createConversationHistoryForAgent = async (
conversationId: string,
) => {
return await prisma.conversationHistory.create({
data: {
conversationId,
message: "Generating...",
userType: "Agent",
thoughts: {},
},
});
};
export const getConversationHistoryFormat = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
previousHistory: any[],
): string => {
if (previousHistory) {
const historyText = previousHistory
.map((history) => `${history.userType}: \n ${history.message}`)
.join("\n------------\n");
return historyText;
}
return "";
};
export const getPreviousExecutionHistory = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
previousHistory: any[],
): CoreMessage[] => {
return previousHistory.map((history) => ({
role: history.userType === "User" ? "user" : "assistant",
content: history.message,
}));
};
export const getIntegrationDefinitionsForAgents = (agents: string[]) => {
return prisma.integrationDefinitionV2.findMany({
where: {
slug: {
in: agents,
},
},
});
};
export const getIntegrationConfigForIntegrationDefinition = (
integrationDefinitionId: string,
) => {
return prisma.integrationAccount.findFirst({
where: {
integrationDefinitionId,
},
});
};
export const updateExecutionStep = async (
step: HistoryStep,
conversationHistoryId: string,
) => {
const {
thought,
userMessage,
skillInput,
skillOutput,
skillId,
skillStatus,
...metadata
} = step;
await prisma.conversationExecutionStep.create({
data: {
thought: thought ?? "",
message: userMessage ?? "",
actionInput:
typeof skillInput === "object"
? JSON.stringify(skillInput)
: skillInput,
actionOutput:
typeof skillOutput === "object"
? JSON.stringify(skillOutput)
: skillOutput,
actionId: skillId,
actionStatus: skillStatus,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: metadata as any,
conversationHistoryId,
},
});
};
export const updateConversationHistoryMessage = async (
userMessage: string,
conversationHistoryId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
thoughts?: Record<string, any>,
) => {
await prisma.conversationHistory.update({
where: {
id: conversationHistoryId,
},
data: {
message: userMessage,
thoughts,
userType: UserType.Agent,
},
});
};
export const getExecutionStepsForConversation = async (
conversationHistoryId: string,
) => {
const lastExecutionSteps = await prisma.conversationExecutionStep.findMany({
where: {
conversationHistoryId,
},
});
return lastExecutionSteps;
};
export const getActivityDetails = async (activityId: string) => {
if (!activityId) {
return {};
}
const activity = await prisma.activity.findFirst({
where: {
id: activityId,
},
});
return {
activityId,
integrationAccountId: activity?.integrationAccountId,
sourceURL: activity?.sourceURL,
};
};
/**
* Generates a random ID of 6 characters
* @returns A random string of 6 characters
*/
export const generateRandomId = (): string => {
// Define characters that can be used in the ID
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
// Generate 6 random characters
for (let i = 0; i < 6; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
result += characters.charAt(randomIndex);
}
return result.toLowerCase();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function flattenObject(obj: Record<string, any>, prefix = ""): string[] {
return Object.entries(obj).reduce<string[]>((result, [key, value]) => {
const entryKey = prefix ? `${prefix}_${key}` : key;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
// For nested objects, flatten them and add to results
return [...result, ...flattenObject(value, entryKey)];
}
// For primitive values or arrays, add directly
return [...result, `- ${entryKey}: ${value}`];
}, []);
}
export const updateConversationStatus = async (
status: string,
conversationId: string,
) => {
const data: Prisma.ConversationUpdateInput = { status, unread: true };
return await prisma.conversation.update({
where: {
id: conversationId,
},
data,
});
};
export const getActivity = async (activityId: string) => {
return await prisma.activity.findUnique({
where: {
id: activityId,
},
include: {
workspace: true,
integrationAccount: {
include: {
integrationDefinition: true,
},
},
},
});
};
export const updateActivity = async (
activityId: string,
rejectionReason: string,
) => {
return await prisma.activity.update({
where: {
id: activityId,
},
data: {
rejectionReason,
},
});
};
export const createConversation = async (
activity: Activity,
workspace: Workspace,
integrationDefinition: IntegrationDefinitionV2,
automationContext: { automations?: string[]; executionPlan: string },
) => {
const conversation = await prisma.conversation.create({
data: {
workspaceId: activity.workspaceId,
userId: workspace.userId as string,
title: activity.text.substring(0, 100),
ConversationHistory: {
create: {
userId: workspace.userId,
message: `Activity from ${integrationDefinition.name} \n Content: ${activity.text}`,
userType: UserType.User,
activityId: activity.id,
thoughts: { ...automationContext },
},
},
},
include: {
ConversationHistory: true,
},
});
return conversation;
};
export async function getContinuationAgentConversationHistory(
conversationId: string,
): Promise<ConversationHistory | null> {
return await prisma.conversationHistory.findFirst({
where: {
conversationId,
userType: "Agent",
deleted: null,
},
orderBy: {
createdAt: "desc",
},
take: 1,
});
}
export async function deletePersonalAccessToken(tokenId: string) {
return await prisma.personalAccessToken.delete({
where: {
id: tokenId,
},
});
}

View File

@ -8,17 +8,23 @@
"dev": "node ./server.mjs",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
"typecheck": "tsc",
"trigger:dev": "pnpm dlx trigger.dev@v4-beta dev"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/google": "^1.2.22",
"@ai-sdk/openai": "^1.3.21",
"@coji/remix-auth-google": "^4.2.0",
"@conform-to/react": "^0.6.1",
"@conform-to/zod": "^0.6.1",
"@core/database": "workspace:*",
"@core/types": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@mjackson/headers": "0.11.1",
"@modelcontextprotocol/sdk": "1.13.2",
"@nichtsam/remix-auth-email-link": "3.0.0",
"@opentelemetry/api": "1.9.0",
"@prisma/client": "*",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
@ -45,21 +51,40 @@
"@remix-run/server-runtime": "2.16.7",
"@remix-run/v1-meta": "^0.1.3",
"@remixicon/react": "^4.2.0",
"@tanstack/react-table": "^8.13.2",
"@prisma/client": "*",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.1.7",
"@tanstack/react-table": "^8.13.2",
"@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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"d3": "^7.9.0",
"dayjs": "^1.11.10",
"date-fns": "^4.1.0",
"express": "^4.18.1",
"dayjs": "^1.11.10",
"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",
"graphology-layout-noverlap": "^0.4.2",
"handlebars": "^4.7.8",
"ioredis": "^5.6.1",
"isbot": "^4.1.0",
"jose": "^5.2.3",
@ -68,18 +93,21 @@
"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",
"react-dom": "^18.2.0",
"react-resizable-panels": "^1.0.9",
"react-virtualized": "^9.22.6",
"remix-auth": "^4.2.0",
"@nichtsam/remix-auth-email-link": "3.0.0",
"remix-auth-oauth2": "^3.4.1",
"remix-themes": "^1.3.1",
"remix-typedjson": "0.3.1",
"remix-utils": "^7.7.0",
"react-resizable-panels": "^1.0.9",
"react-virtualized": "^9.22.6",
"sdk": "link:@modelcontextprotocol/sdk",
"sigma": "^3.0.2",
"simple-oauth2": "^5.1.0",
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss-animate": "^1.0.7",
@ -96,12 +124,15 @@
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.7",
"@trigger.dev/build": "^4.0.0-v4-beta.22",
"@types/compression": "^1.7.2",
"@types/d3": "^7.4.3",
"@types/express": "^4.17.13",
"@types/morgan": "^1.9.3",
"@types/simple-oauth2": "^5.0.7",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-virtualized": "^9.22.0",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",

View File

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

View File

@ -0,0 +1,38 @@
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,
runtime: "node",
logLevel: "log",
// The max compute seconds a task is allowed to run. If the task run exceeds this duration, it will be stopped.
// You can override this on an individual task.
// See https://trigger.dev/docs/runs/max-duration
maxDuration: 3600,
retries: {
enabledInDev: true,
default: {
maxAttempts: 1,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10000,
factor: 2,
randomize: true,
},
},
dirs: ["./app/trigger"],
build: {
extensions: [
syncEnvVars(() => ({
DATABASE_URL: process.env.DATABASE_URL,
BACKEND_HOST: process.env.BACKEND_HOST,
})),
prismaExtension({
schema: "prisma/schema.prisma",
}),
],
},
});

View File

@ -6,7 +6,8 @@
"**/*.ts",
"**/*.tsx",
"tailwind.config.js",
"tailwind.config.js"
"tailwind.config.js",
"trigger.config.ts"
],
"compilerOptions": {
"types": ["@remix-run/node", "vite/client"],
@ -24,6 +25,7 @@
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"],

View File

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

21
core/types/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "@echo/core-types",
"version": "1.0.0",
"description": "Core types for Echo integrations",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}

1
core/types/src/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './integration';

View File

@ -0,0 +1,64 @@
export enum IntegrationEventType {
/**
* Setting up or creating an integration account
*/
SETUP = "setup",
/**
* Processing incoming data from the integration
*/
PROCESS = "process",
/**
* Identifying which account a webhook belongs to
*/
IDENTIFY = "identify",
/**
* Scheduled synchronization of data
*/
SYNC = "sync",
}
export interface IntegrationEventPayload {
event: IntegrationEventType;
[x: string]: any;
}
export interface Spec {
name: string;
key: string;
description: string;
icon: string;
mcp?: {
command: string;
args: string[];
env: Record<string, string>;
};
auth?: {
OAuth2?: {
token_url: string;
authorization_url: string;
scopes: string[];
scope_identifier?: string;
scope_separator?: string;
};
};
}
export interface Config {
access_token: string;
[key: string]: any;
}
export interface Identifier {
id: string;
type?: string;
}
export type MessageType = 'spec' | 'data' | 'identifier';
export interface Message {
type: MessageType;
data: any;
}

18
core/types/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "*.ts"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More