mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-09 22:48:39 +00:00
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:
parent
a819a682a2
commit
54e535d57d
@ -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
9
.gitignore
vendored
@ -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
57
LICENSE
@ -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.
|
||||
|
||||
1
apps/logs/errors/llm-error-2025-07-08T14-35-41.438Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-35-41.438Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-35-57.972Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-35-57.972Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-36-35.479Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-36-35.479Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-37-12.989Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-37-12.989Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-37-50.503Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-37-50.503Z.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/logs/errors/llm-error-2025-07-08T14-38-28.011Z.json
Normal file
1
apps/logs/errors/llm-error-2025-07-08T14-38-28.011Z.json
Normal file
File diff suppressed because one or more lines are too long
2
apps/webapp/.gitignore
vendored
2
apps/webapp/.gitignore
vendored
@ -3,3 +3,5 @@ node_modules
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
||||
.trigger
|
||||
@ -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>}
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { UserTypeEnum } from "@core/types";
|
||||
import { type ConversationHistory } from "@core/database";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface AIConversationItemProps {
|
||||
conversationHistory: ConversationHistory;
|
||||
}
|
||||
|
||||
export const ConversationItem = ({
|
||||
conversationHistory,
|
||||
}: AIConversationItemProps) => {
|
||||
const isUser =
|
||||
conversationHistory.userType === UserTypeEnum.User ||
|
||||
conversationHistory.userType === UserTypeEnum.System;
|
||||
|
||||
const id = `a${conversationHistory.id.replace(/-/g, "")}`;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
],
|
||||
editable: false,
|
||||
content: conversationHistory.message,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
editor?.commands.setContent(conversationHistory.message);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, conversationHistory.message]);
|
||||
|
||||
if (!conversationHistory.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2 px-4 pb-2", isUser && "my-4 justify-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isUser && "bg-primary/20 max-w-[500px] rounded-md p-3",
|
||||
)}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
245
apps/webapp/app/components/conversation/conversation-list.tsx
Normal file
245
apps/webapp/app/components/conversation/conversation-list.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
List,
|
||||
AutoSizer,
|
||||
InfiniteLoader,
|
||||
type ListRowRenderer,
|
||||
} from "react-virtualized";
|
||||
import { format } from "date-fns";
|
||||
import { MessageSquare, Clock } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "../ui";
|
||||
|
||||
type ConversationItem = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
unread: boolean;
|
||||
status: string;
|
||||
ConversationHistory: Array<{
|
||||
id: string;
|
||||
message: string;
|
||||
userType: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ConversationListResponse = {
|
||||
conversations: ConversationItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const ConversationList = ({
|
||||
currentConversationId,
|
||||
}: {
|
||||
currentConversationId?: string;
|
||||
}) => {
|
||||
const fetcher = useFetcher<ConversationListResponse>();
|
||||
const [conversations, setConversations] = useState<ConversationItem[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasNextPage, setHasNextPage] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// const [searchTerm, setSearchTerm] = useState("");
|
||||
// const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const loadMoreConversations = useCallback(
|
||||
(page: number) => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: "25",
|
||||
});
|
||||
|
||||
fetcher.load(`/api/v1/conversations?${searchParams}`, {
|
||||
flushSync: true,
|
||||
});
|
||||
},
|
||||
[isLoading, fetcher, currentPage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreConversations(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data && fetcher.state === "idle") {
|
||||
setIsLoading(false);
|
||||
const response = fetcher.data;
|
||||
|
||||
if (currentPage === 1) {
|
||||
setConversations(response.conversations);
|
||||
} else {
|
||||
setConversations((prev) => [...prev, ...response.conversations]);
|
||||
}
|
||||
|
||||
setHasNextPage(response.pagination.hasNext);
|
||||
setCurrentPage(response.pagination.page);
|
||||
}
|
||||
}, [fetcher.data, fetcher.state, currentPage]);
|
||||
|
||||
// const handleSearch = useCallback(
|
||||
// (term: string) => {
|
||||
// setSearchTerm(term);
|
||||
// setCurrentPage(1);
|
||||
// setConversations([]);
|
||||
// setHasNextPage(true);
|
||||
|
||||
// if (searchTimeoutRef.current) {
|
||||
// clearTimeout(searchTimeoutRef.current);
|
||||
// }
|
||||
|
||||
// searchTimeoutRef.current = setTimeout(() => {
|
||||
// loadMoreConversations(1);
|
||||
// }, 300);
|
||||
// },
|
||||
// [loadMoreConversations],
|
||||
// );
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
({ index }: { index: number }) => {
|
||||
return !!conversations[index];
|
||||
},
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const loadMoreRows = useCallback(() => {
|
||||
if (!hasNextPage || isLoading) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
if (conversations.length === 25) {
|
||||
const nextPage = currentPage + 1;
|
||||
loadMoreConversations(nextPage);
|
||||
const checkLoaded = () => {
|
||||
if (!isLoading) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkLoaded, 100);
|
||||
}
|
||||
};
|
||||
checkLoaded();
|
||||
}
|
||||
});
|
||||
}, [
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
currentPage,
|
||||
loadMoreConversations,
|
||||
conversations,
|
||||
]);
|
||||
|
||||
const rowRenderer: ListRowRenderer = useCallback(
|
||||
({ index, key, style }) => {
|
||||
const conversation = conversations[index];
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"border-border h-auto w-full justify-start p-2 text-left",
|
||||
currentConversationId === conversation.id && "bg-grayAlpha-100",
|
||||
)}
|
||||
onClick={() => {
|
||||
window.location.href = `/home/conversation/${conversation.id}`;
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start space-x-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={cn("truncate font-normal")}>
|
||||
{conversation.title || "Untitled Conversation"}
|
||||
</p>
|
||||
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
|
||||
<span>
|
||||
{format(new Date(conversation.updatedAt), "MMM d")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const rowCount = hasNextPage
|
||||
? conversations.length + 1
|
||||
: conversations.length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* <div className="border-b">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
className="focus:ring-primary w-full rounded-none px-3 py-2 focus:ring-2 focus:outline-none"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<InfiniteLoader
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={rowCount}
|
||||
threshold={5}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
ref={registerChild}
|
||||
height={height}
|
||||
width={width}
|
||||
rowCount={rowCount}
|
||||
rowHeight={40}
|
||||
onRowsRendered={onRowsRendered}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={5}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
</div>
|
||||
|
||||
{isLoading && conversations.length === 0 && (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Loading conversations...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,175 @@
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { type Editor } from "@tiptap/react";
|
||||
import { EditorContent, Placeholder, EditorRoot } from "novel";
|
||||
import { useCallback, useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "../ui";
|
||||
import { Loader } from "lucide-react";
|
||||
import { Form, useSubmit } from "@remix-run/react";
|
||||
|
||||
interface ConversationTextareaProps {
|
||||
defaultValue?: string;
|
||||
conversationId: string;
|
||||
placeholder?: string;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
onChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationTextarea({
|
||||
defaultValue,
|
||||
isLoading = false,
|
||||
placeholder,
|
||||
className,
|
||||
conversationId,
|
||||
onChange,
|
||||
}: ConversationTextareaProps) {
|
||||
const [text, setText] = useState(defaultValue ?? "");
|
||||
const [editor, setEditor] = useState<Editor>();
|
||||
const submit = useSubmit();
|
||||
|
||||
const onUpdate = (editor: Editor) => {
|
||||
setText(editor.getHTML());
|
||||
onChange && onChange(editor.getText());
|
||||
};
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (!editor || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = isLoading
|
||||
? {}
|
||||
: { message: text, title: text, conversationId };
|
||||
|
||||
submit(data as any, {
|
||||
action: isLoading
|
||||
? `/home/conversation/${conversationId}`
|
||||
: "/home/conversation",
|
||||
method: "post",
|
||||
});
|
||||
|
||||
editor?.commands.clearContent(true);
|
||||
setText("");
|
||||
|
||||
editor.commands.clearContent(true);
|
||||
setText("");
|
||||
}, [editor, text]);
|
||||
|
||||
// Send message to API
|
||||
const submitForm = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const data = isLoading
|
||||
? {}
|
||||
: { message: text, title: text, conversationId };
|
||||
|
||||
submit(data as any, {
|
||||
action: isLoading
|
||||
? `/home/conversation/${conversationId}`
|
||||
: "/home/conversation",
|
||||
method: "post",
|
||||
});
|
||||
|
||||
editor?.commands.clearContent(true);
|
||||
setText("");
|
||||
e.preventDefault();
|
||||
},
|
||||
[text, conversationId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
action="/home/conversation"
|
||||
method="post"
|
||||
onSubmit={(e) => submitForm(e)}
|
||||
className="pt-2"
|
||||
>
|
||||
<div className="bg-background-3 rounded py-2">
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
initialContent={defaultValue as any}
|
||||
extensions={[
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
|
||||
Placeholder.configure({
|
||||
placeholder: () => placeholder ?? "Ask sol...",
|
||||
includeChildren: true,
|
||||
}),
|
||||
History,
|
||||
]}
|
||||
onCreate={async ({ editor }) => {
|
||||
setEditor(editor);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
editor.commands.focus("end");
|
||||
}}
|
||||
onUpdate={({ editor }) => {
|
||||
onUpdate(editor);
|
||||
}}
|
||||
shouldRerenderOnTransaction={false}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
|
||||
},
|
||||
handleKeyDown(view, event) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const target = event.target as any;
|
||||
if (target.innerHTML.includes("suggestion")) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (text) {
|
||||
handleSend();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && event.shiftKey) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceSelectionWith(
|
||||
view.state.schema.nodes.hardBreak.create(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
immediatelyRender={false}
|
||||
className={cn(
|
||||
"editor-container text-md max-h-[400px] min-h-[40px] w-full min-w-full overflow-auto px-3 sm:rounded-lg",
|
||||
)}
|
||||
/>
|
||||
</EditorRoot>
|
||||
<div className="flex justify-end px-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
|
||||
type="submit"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader size={18} className="mr-1 animate-spin" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>Chat</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
151
apps/webapp/app/components/conversation/conversation.client.tsx
Normal file
151
apps/webapp/app/components/conversation/conversation.client.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { EditorRoot, EditorContent, Placeholder } from "novel";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Form, useNavigate, useSubmit } from "@remix-run/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Button } from "../ui";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "../ui/resizable";
|
||||
import { ConversationList } from "./conversation-list";
|
||||
|
||||
export const ConversationNew = ({
|
||||
user,
|
||||
}: {
|
||||
user: { name: string | null };
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const submit = useSubmit();
|
||||
|
||||
// Send message to API
|
||||
const submitForm = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
submit(
|
||||
{ message: content, title: content },
|
||||
{
|
||||
action: "/home/conversation",
|
||||
method: "post",
|
||||
},
|
||||
);
|
||||
e.preventDefault();
|
||||
},
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
|
||||
<ResizablePanel
|
||||
maxSize={50}
|
||||
defaultSize={16}
|
||||
minSize={16}
|
||||
collapsible
|
||||
collapsedSize={16}
|
||||
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
|
||||
>
|
||||
<ConversationList />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-1" />
|
||||
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
|
||||
>
|
||||
<Form
|
||||
action="/home/conversation"
|
||||
method="post"
|
||||
onSubmit={(e) => submitForm(e)}
|
||||
className="pt-2"
|
||||
>
|
||||
<div className={cn("flex h-[calc(100vh_-_60px)] flex-col")}>
|
||||
<div className="flex h-full w-full flex-col items-start justify-start overflow-y-auto p-4">
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<div className="w-full max-w-[90ch]">
|
||||
<h1 className="mx-1 mb-4 text-left text-[32px] font-medium">
|
||||
Hello <span className="text-primary">{user.name}</span>
|
||||
</h1>
|
||||
<div className="bg-background-3 border-border rounded-lg border-1 py-2">
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
ref={editorRef}
|
||||
autofocus
|
||||
extensions={[
|
||||
Placeholder.configure({
|
||||
placeholder: () => {
|
||||
return "Ask sol...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
History,
|
||||
]}
|
||||
editorProps={{
|
||||
attributes: {
|
||||
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
|
||||
},
|
||||
handleKeyDown: (_view: any, event: KeyboardEvent) => {
|
||||
// This is the ProseMirror event, not React's
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (content) {
|
||||
submit(
|
||||
{ message: content, title: content },
|
||||
{
|
||||
action: "/home/conversation",
|
||||
method: "post",
|
||||
},
|
||||
);
|
||||
|
||||
setContent("");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
immediatelyRender={false}
|
||||
className={cn(
|
||||
"editor-container text-md max-h-[400px] min-h-[30px] w-full min-w-full overflow-auto px-3 pt-1 sm:rounded-lg",
|
||||
)}
|
||||
onUpdate={({ editor }: { editor: any }) => {
|
||||
const html = editor.getHTML();
|
||||
setContent(html);
|
||||
}}
|
||||
/>
|
||||
</EditorRoot>
|
||||
<div className="flex justify-end px-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-1 shadow-none transition-all duration-500 ease-in-out"
|
||||
type="submit"
|
||||
size="lg"
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
5
apps/webapp/app/components/conversation/index.ts
Normal file
5
apps/webapp/app/components/conversation/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./conversation.client";
|
||||
export * from "./conversation-item.client";
|
||||
export * from "./streaming-conversation.client";
|
||||
export * from "./conversation-textarea.client";
|
||||
export * from "./conversation-list";
|
||||
@ -0,0 +1,106 @@
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import React from "react";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
|
||||
import { useTriggerStream } from "./use-trigger-stream";
|
||||
import { Placeholder } from "novel";
|
||||
|
||||
interface StreamingConversationProps {
|
||||
runId: string;
|
||||
token: string;
|
||||
afterStreaming: () => void;
|
||||
apiURL: string;
|
||||
}
|
||||
|
||||
export const StreamingConversation = ({
|
||||
runId,
|
||||
token,
|
||||
afterStreaming,
|
||||
apiURL,
|
||||
}: StreamingConversationProps) => {
|
||||
const { message, isEnd } = useTriggerStream(runId, token, apiURL);
|
||||
const [loadingText, setLoadingText] = React.useState("Thinking...");
|
||||
|
||||
const loadingMessages = [
|
||||
"Thinking...",
|
||||
"Still thinking...",
|
||||
"Deep in thought...",
|
||||
"Processing at light speed...",
|
||||
"Loading SOL...",
|
||||
"Establishing Mars connection...",
|
||||
"Consulting the Martian archives...",
|
||||
"Calculating in Mars time...",
|
||||
"Warming up the quantum processors...",
|
||||
"Checking atmospheric conditions on Mars...",
|
||||
"Untangling red planet algorithms...",
|
||||
"Just need my Mars-roasted coffee...",
|
||||
];
|
||||
|
||||
const messagesEditor = useEditor({
|
||||
extensions: [
|
||||
Placeholder,
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
HardBreak.configure({
|
||||
keepMarks: true,
|
||||
}),
|
||||
],
|
||||
editable: false,
|
||||
content: "",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (message) {
|
||||
messagesEditor?.commands.setContent(message);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [message]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEnd) {
|
||||
afterStreaming();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEnd]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let currentIndex = 0;
|
||||
let delay = 5000; // Start with 2 seconds for more thinking time
|
||||
|
||||
const updateLoadingText = () => {
|
||||
if (!message) {
|
||||
setLoadingText(loadingMessages[currentIndex]);
|
||||
currentIndex = (currentIndex + 1) % loadingMessages.length;
|
||||
delay = Math.min(delay * 1.3, 8000); // Increase delay more gradually
|
||||
setTimeout(updateLoadingText, delay);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(updateLoadingText, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-5 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{message ? (
|
||||
<EditorContent
|
||||
editor={messagesEditor}
|
||||
className="text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-foreground italic">{loadingText}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
103
apps/webapp/app/components/conversation/use-trigger-stream.tsx
Normal file
103
apps/webapp/app/components/conversation/use-trigger-stream.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
import React from "react";
|
||||
|
||||
export const useTriggerStream = (
|
||||
runId: string,
|
||||
token: string,
|
||||
apiURL?: string,
|
||||
) => {
|
||||
const { error, streams, run } = useRealtimeRunWithStreams(runId, {
|
||||
accessToken: token,
|
||||
baseURL: apiURL ?? "https://trigger.heysol.ai", // Optional if you are using a self-hosted Trigger.dev instance
|
||||
});
|
||||
|
||||
const isEnd = React.useMemo(() => {
|
||||
if (error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
run &&
|
||||
[
|
||||
"COMPLETED",
|
||||
"CANCELED",
|
||||
"FAILED",
|
||||
"CRASHED",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
"EXPIRED",
|
||||
"TIMED_OUT",
|
||||
].includes(run?.status)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasStreamEnd =
|
||||
streams.messages &&
|
||||
streams.messages.filter((item) => {
|
||||
// Check if the item has a type that includes 'MESSAGE_' and is not empty
|
||||
return item.type?.includes("STREAM_END");
|
||||
});
|
||||
|
||||
if (hasStreamEnd && hasStreamEnd.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [run?.status, error, streams.messages?.length]);
|
||||
|
||||
const message = React.useMemo(() => {
|
||||
if (!streams?.messages) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Filter and combine all message chunks
|
||||
return streams.messages
|
||||
.filter((item) => {
|
||||
// Check if the item has a type that includes 'MESSAGE_' and is not empty
|
||||
return item.type?.includes("MESSAGE_");
|
||||
})
|
||||
.map((item) => item.message)
|
||||
.join("");
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streams.messages?.length]);
|
||||
|
||||
const actionMessages = React.useMemo(() => {
|
||||
if (!streams?.messages) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messages: Record<string, { isStreaming: boolean; content: any[] }> =
|
||||
{};
|
||||
|
||||
streams.messages.forEach((item) => {
|
||||
if (item.type?.includes("SKILL_")) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.message);
|
||||
const skillId = parsed.skillId;
|
||||
|
||||
if (!messages[skillId]) {
|
||||
messages[skillId] = { isStreaming: true, content: [] };
|
||||
}
|
||||
|
||||
if (item.type === "SKILL_END") {
|
||||
messages[skillId].isStreaming = false;
|
||||
}
|
||||
|
||||
messages[skillId].content.push(parsed);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return messages;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streams.messages?.length]);
|
||||
|
||||
return { isEnd, message, actionMessages };
|
||||
};
|
||||
20
apps/webapp/app/components/graph/graph-client.tsx
Normal file
20
apps/webapp/app/components/graph/graph-client.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
42
apps/webapp/app/components/logo/core.svg
Normal file
42
apps/webapp/app/components/logo/core.svg
Normal 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
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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
|
||||
|
||||
32
apps/webapp/app/components/ui/header.tsx
Normal file
32
apps/webapp/app/components/ui/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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))]",
|
||||
},
|
||||
|
||||
169
apps/webapp/app/components/use-auto-scroll.tsx
Normal file
169
apps/webapp/app/components/use-auto-scroll.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
// @hidden
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ScrollState {
|
||||
isAtBottom: boolean;
|
||||
autoScrollEnabled: boolean;
|
||||
}
|
||||
|
||||
interface UseAutoScrollOptions {
|
||||
offset?: number;
|
||||
smooth?: boolean;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function useAutoScroll(options: UseAutoScrollOptions = {}) {
|
||||
const { offset = 20, smooth = false, content } = options;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lastContentHeight = useRef(0);
|
||||
const userHasScrolled = useRef(false);
|
||||
|
||||
const [scrollState, setScrollState] = useState<ScrollState>({
|
||||
isAtBottom: false,
|
||||
autoScrollEnabled: true,
|
||||
});
|
||||
|
||||
const checkIsAtBottom = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
const distanceToBottom = Math.abs(
|
||||
scrollHeight - scrollTop - clientHeight,
|
||||
);
|
||||
return distanceToBottom <= offset;
|
||||
},
|
||||
[offset],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(instant?: boolean) => {
|
||||
if (scrollRef.current) {
|
||||
const targetScrollTop =
|
||||
scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
|
||||
|
||||
if (instant) {
|
||||
scrollRef.current.scrollTop = targetScrollTop;
|
||||
} else {
|
||||
scrollRef.current.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
}
|
||||
|
||||
setScrollState({
|
||||
isAtBottom: true,
|
||||
autoScrollEnabled: true,
|
||||
});
|
||||
userHasScrolled.current = false;
|
||||
}
|
||||
},
|
||||
[smooth],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
const atBottom = checkIsAtBottom(scrollRef.current);
|
||||
|
||||
setScrollState((prev) => ({
|
||||
isAtBottom: atBottom,
|
||||
// Re-enable auto-scroll if at the bottom
|
||||
autoScrollEnabled: atBottom ? true : prev.autoScrollEnabled,
|
||||
}));
|
||||
}
|
||||
}, [checkIsAtBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (element) {
|
||||
element.addEventListener("scroll", handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
return () =>
|
||||
element ? element.removeEventListener("scroll", handleScroll) : undefined;
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHeight = scrollElement.scrollHeight;
|
||||
const hasNewContent = currentHeight !== lastContentHeight.current;
|
||||
|
||||
if (hasNewContent) {
|
||||
if (scrollState.autoScrollEnabled) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom(lastContentHeight.current === 0);
|
||||
});
|
||||
}
|
||||
lastContentHeight.current = currentHeight;
|
||||
}
|
||||
}, [content, scrollState.autoScrollEnabled, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (scrollState.autoScrollEnabled) {
|
||||
scrollToBottom(true);
|
||||
}
|
||||
});
|
||||
|
||||
const element = scrollRef.current;
|
||||
if (element) {
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [scrollState.autoScrollEnabled, scrollToBottom]);
|
||||
|
||||
const disableAutoScroll = useCallback(() => {
|
||||
const atBottom = scrollRef.current
|
||||
? checkIsAtBottom(scrollRef.current)
|
||||
: false;
|
||||
|
||||
// Only disable if not at bottom
|
||||
if (!atBottom) {
|
||||
userHasScrolled.current = true;
|
||||
setScrollState((prev) => ({
|
||||
...prev,
|
||||
autoScrollEnabled: false,
|
||||
}));
|
||||
}
|
||||
}, [checkIsAtBottom]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
isAtBottom: scrollState.isAtBottom,
|
||||
autoScrollEnabled: scrollState.autoScrollEnabled,
|
||||
scrollToBottom: () => scrollToBottom(false),
|
||||
disableAutoScroll,
|
||||
};
|
||||
}
|
||||
|
||||
export const ScrollAreaWithAutoScroll = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { scrollRef } = useAutoScroll({
|
||||
smooth: true,
|
||||
content: children,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"flex grow flex-col items-center overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full max-w-[97ch] flex-col pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 />
|
||||
|
||||
|
||||
21
apps/webapp/app/routes/api.v1.add.tsx
Normal file
21
apps/webapp/app/routes/api.v1.add.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
body: IngestBodyRequest,
|
||||
allowJWT: true,
|
||||
authorization: {
|
||||
action: "ingest",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
const response = addToQueue(body, authentication.userId);
|
||||
return json({ ...response });
|
||||
},
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
@ -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 };
|
||||
37
apps/webapp/app/routes/api.v1.conversation._index.tsx
Normal file
37
apps/webapp/app/routes/api.v1.conversation._index.tsx
Normal 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 };
|
||||
41
apps/webapp/app/routes/api.v1.conversations.tsx
Normal file
41
apps/webapp/app/routes/api.v1.conversations.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||
import {
|
||||
getConversationsList,
|
||||
GetConversationsListSchema,
|
||||
} from "~/services/conversation.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request }: { request: Request }) => {
|
||||
// Authenticate the request (allow JWT)
|
||||
const user = await requireUser(request);
|
||||
|
||||
// Parse search params using the schema
|
||||
const url = new URL(request.url);
|
||||
const searchParamsObj: Record<string, string> = {};
|
||||
url.searchParams.forEach((value, key) => {
|
||||
searchParamsObj[key] = value;
|
||||
});
|
||||
const parseResult = GetConversationsListSchema.safeParse(searchParamsObj);
|
||||
if (!parseResult.success) {
|
||||
return json(
|
||||
{ error: "Invalid search parameters", details: parseResult.error.errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const searchParams = parseResult.data;
|
||||
|
||||
const workspace = await getWorkspaceByUser(user.id);
|
||||
|
||||
if (!workspace) {
|
||||
return json({ error: "No workspace found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await getConversationsList(
|
||||
workspace.id,
|
||||
user.id,
|
||||
searchParams || {},
|
||||
);
|
||||
|
||||
return json(result);
|
||||
};
|
||||
32
apps/webapp/app/routes/api.v1.oauth._index.tsx
Normal file
32
apps/webapp/app/routes/api.v1.oauth._index.tsx
Normal 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 };
|
||||
21
apps/webapp/app/routes/api.v1.oauth.callback.tsx
Normal file
21
apps/webapp/app/routes/api.v1.oauth.callback.tsx
Normal 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 };
|
||||
50
apps/webapp/app/routes/api.v1.search.tsx
Normal file
50
apps/webapp/app/routes/api.v1.search.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { SearchService } from "~/services/search.server";
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
export const SearchBodyRequest = z.object({
|
||||
query: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
|
||||
// These are not supported yet, but need to support these
|
||||
spaceId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
maxBfsDepth: z.number().optional(),
|
||||
includeInvalidated: z.boolean().optional(),
|
||||
entityTypes: z.array(z.string()).optional(),
|
||||
scoreThreshold: z.number().optional(),
|
||||
minResults: z.number().optional(),
|
||||
});
|
||||
|
||||
const searchService = new SearchService();
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
body: SearchBodyRequest,
|
||||
allowJWT: true,
|
||||
authorization: {
|
||||
action: "search",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
const results = await searchService.search(
|
||||
body.query,
|
||||
authentication.userId,
|
||||
{
|
||||
startTime: body.startTime ? new Date(body.startTime) : undefined,
|
||||
endTime: body.endTime ? new Date(body.endTime) : undefined,
|
||||
limit: body.limit,
|
||||
maxBfsDepth: body.maxBfsDepth,
|
||||
includeInvalidated: body.includeInvalidated,
|
||||
entityTypes: body.entityTypes,
|
||||
scoreThreshold: body.scoreThreshold,
|
||||
minResults: body.minResults,
|
||||
},
|
||||
);
|
||||
return json(results);
|
||||
},
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
@ -3,7 +3,7 @@ import { useActionData } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/node";
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { getFieldsetConstraint, parse } from "@conform-to/zod";
|
||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
||||
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -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"
|
||||
|
||||
187
apps/webapp/app/routes/home.conversation.$conversationId.tsx
Normal file
187
apps/webapp/app/routes/home.conversation.$conversationId.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { sort } from "fast-sort";
|
||||
|
||||
import { useParams, useRevalidator } from "@remix-run/react";
|
||||
import {
|
||||
requireUser,
|
||||
requireUserId,
|
||||
requireWorkpace,
|
||||
} from "~/services/session.server";
|
||||
import {
|
||||
getConversationAndHistory,
|
||||
getCurrentConversationRun,
|
||||
stopConversation,
|
||||
} from "~/services/conversation.server";
|
||||
import { type ConversationHistory } from "@core/database";
|
||||
import {
|
||||
ConversationItem,
|
||||
ConversationList,
|
||||
ConversationTextarea,
|
||||
StreamingConversation,
|
||||
} from "~/components/conversation";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import React from "react";
|
||||
import { ScrollAreaWithAutoScroll } from "~/components/use-auto-scroll";
|
||||
|
||||
import { json } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
|
||||
// Example loader accessing params
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
const workspace = await requireWorkpace(request);
|
||||
const conversation = await getConversationAndHistory(
|
||||
params.conversationId as string,
|
||||
user.id,
|
||||
);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error("No conversation found");
|
||||
}
|
||||
|
||||
const run = await getCurrentConversationRun(conversation.id, workspace.id);
|
||||
|
||||
return { conversation, run, apiURL: env.TRIGGER_API_URL };
|
||||
}
|
||||
|
||||
// Example action accessing params
|
||||
export async function action({ params, request }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
const userId = await requireUserId(request);
|
||||
const workspace = await requireWorkpace(request);
|
||||
// params.conversationId will be available here
|
||||
const { conversationId } = params;
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation");
|
||||
}
|
||||
|
||||
const result = await stopConversation(conversationId, workspace.id);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
// Accessing params in the component
|
||||
export default function SingleConversation() {
|
||||
const { conversation, run, apiURL } = useTypedLoaderData<typeof loader>();
|
||||
const conversationHistory = conversation.ConversationHistory;
|
||||
|
||||
const [conversationResponse, setConversationResponse] = React.useState<
|
||||
{ conversationHistoryId: string; id: string; token: string } | undefined
|
||||
>(run);
|
||||
|
||||
const [stopLoading, setStopLoading] = React.useState(false);
|
||||
|
||||
const { conversationId } = useParams();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (run) {
|
||||
setConversationResponse(run);
|
||||
}
|
||||
}, [run]);
|
||||
|
||||
const getConversations = () => {
|
||||
const lastConversationHistoryId =
|
||||
conversationResponse?.conversationHistoryId;
|
||||
|
||||
// First sort the conversation history by creation time
|
||||
const sortedConversationHistory = sort(conversationHistory).asc(
|
||||
(ch) => ch.createdAt,
|
||||
);
|
||||
|
||||
const lastIndex = sortedConversationHistory.findIndex(
|
||||
(item) => item.id === lastConversationHistoryId,
|
||||
);
|
||||
|
||||
// Filter out any conversation history items that come after the lastConversationHistoryId
|
||||
const filteredConversationHistory = lastConversationHistoryId
|
||||
? sortedConversationHistory.filter((_ch, currentIndex: number) => {
|
||||
// Find the index of the last conversation history
|
||||
|
||||
// Only keep items that come before or are the last conversation history
|
||||
return currentIndex <= lastIndex;
|
||||
})
|
||||
: sortedConversationHistory;
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredConversationHistory.map(
|
||||
(ch: ConversationHistory, index: number) => {
|
||||
return <ConversationItem key={index} conversationHistory={ch} />;
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="bg-background-2">
|
||||
<ResizablePanel
|
||||
maxSize={50}
|
||||
defaultSize={16}
|
||||
minSize={16}
|
||||
collapsible
|
||||
collapsedSize={16}
|
||||
className="border-border h-[calc(100vh_-_60px)] min-w-[200px] border-r-1"
|
||||
>
|
||||
<ConversationList currentConversationId={conversationId} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-1" />
|
||||
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="flex h-[calc(100vh_-_24px)] w-full flex-col"
|
||||
>
|
||||
<div className="relative flex h-[calc(100vh_-_70px)] w-full flex-col items-center justify-center overflow-auto">
|
||||
<div className="flex h-[calc(100vh_-_60px)] w-full flex-col justify-end overflow-hidden">
|
||||
<ScrollAreaWithAutoScroll>
|
||||
{getConversations()}
|
||||
{conversationResponse && (
|
||||
<StreamingConversation
|
||||
runId={conversationResponse.id}
|
||||
token={conversationResponse.token}
|
||||
afterStreaming={() => {
|
||||
setConversationResponse(undefined);
|
||||
revalidator.revalidate();
|
||||
}}
|
||||
apiURL={apiURL}
|
||||
/>
|
||||
)}
|
||||
</ScrollAreaWithAutoScroll>
|
||||
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<div className="w-full max-w-[97ch] px-1 pr-2">
|
||||
{conversation?.status !== "need_approval" && (
|
||||
<ConversationTextarea
|
||||
conversationId={conversationId as string}
|
||||
className="bg-background-3 w-full border-1 border-gray-300"
|
||||
isLoading={
|
||||
!!conversationResponse ||
|
||||
conversation?.status === "running" ||
|
||||
stopLoading
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
72
apps/webapp/app/routes/home.conversation._index.tsx
Normal file
72
apps/webapp/app/routes/home.conversation._index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { parse } from "@conform-to/zod";
|
||||
|
||||
import {
|
||||
requireUser,
|
||||
requireUserId,
|
||||
requireWorkpace,
|
||||
} from "~/services/session.server";
|
||||
|
||||
import { ConversationNew } from "~/components/conversation";
|
||||
import {
|
||||
createConversation,
|
||||
CreateConversationSchema,
|
||||
} from "~/services/conversation.server";
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Only return userId, not the heavy nodeLinks
|
||||
const user = await requireUser(request);
|
||||
|
||||
return { user };
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
}
|
||||
|
||||
const userId = await requireUserId(request);
|
||||
const workspace = await requireWorkpace(request);
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = parse(formData, { schema: CreateConversationSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const conversation = await createConversation(workspace?.id, userId, {
|
||||
message: submission.value.message,
|
||||
title: submission.value.title,
|
||||
conversationId: submission.value.conversationId,
|
||||
});
|
||||
|
||||
// Redirect to the conversation page after creation
|
||||
// conversationId may be in different places depending on createConversation logic
|
||||
const conversationId = conversation?.conversationId;
|
||||
|
||||
if (conversationId) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/home/conversation/${conversationId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// fallback: just return the conversation object
|
||||
return json({ conversation });
|
||||
}
|
||||
|
||||
export default function Chat() {
|
||||
const { user } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>{typeof window !== "undefined" && <ConversationNew user={user} />}</>
|
||||
);
|
||||
}
|
||||
@ -1,29 +1,19 @@
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { Ingest } from "~/components/dashboard/ingest";
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { addToQueue, IngestBodyRequest } from "~/lib/ingest.server";
|
||||
import { getNodeLinks } from "~/lib/neo4j.server";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
// DEPRECATED: This route is deprecated. Please use /api/v1/add instead.
|
||||
// The API logic has been moved to /api/v1/add. This file is retained for reference only.
|
||||
|
||||
import { json } from "@remix-run/node";
|
||||
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
||||
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||
import { Fieldset } from "~/components/ui/Fieldset";
|
||||
import { isGoogleAuthSupported } from "~/services/auth.server";
|
||||
import { setRedirectTo } from "~/services/redirectTo.server";
|
||||
|
||||
@ -16,7 +16,7 @@ import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Inbox, Loader, Mail } from "lucide-react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { LoginPageLayout } from "~/components/layout/LoginPageLayout";
|
||||
import { LoginPageLayout } from "~/components/layout/login-page-layout";
|
||||
import { Button } from "~/components/ui";
|
||||
import { Fieldset } from "~/components/ui/Fieldset";
|
||||
import { FormButtons } from "~/components/ui/FormButtons";
|
||||
|
||||
@ -1,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 };
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
};
|
||||
112
apps/webapp/app/routes/settings.tsx
Normal file
112
apps/webapp/app/routes/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
361
apps/webapp/app/services/conversation.server.ts
Normal file
361
apps/webapp/app/services/conversation.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
88
apps/webapp/app/services/integration.server.ts
Normal file
88
apps/webapp/app/services/integration.server.ts
Normal 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}`);
|
||||
}
|
||||
24
apps/webapp/app/services/integrationDefinition.server.ts
Normal file
24
apps/webapp/app/services/integrationDefinition.server.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@ -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({
|
||||
|
||||
155
apps/webapp/app/services/oauth/oauth-utils.server.ts
Normal file
155
apps/webapp/app/services/oauth/oauth-utils.server.ts
Normal 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;
|
||||
}
|
||||
245
apps/webapp/app/services/oauth/oauth.server.ts
Normal file
245
apps/webapp/app/services/oauth/oauth.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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)}`;
|
||||
|
||||
@ -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: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,7 +62,17 @@ export async function requireUser(request: Request) {
|
||||
|
||||
export async function requireWorkpace(request: Request) {
|
||||
const userId = await requireUserId(request);
|
||||
return getWorkspaceByUser(userId);
|
||||
const workspace = await getWorkspaceByUser(userId);
|
||||
|
||||
if (!workspace) {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = new URLSearchParams([
|
||||
["redirectTo", `${url.pathname}${url.search}`],
|
||||
]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
--foreground: oklch(0% 0 0);
|
||||
--popover: oklch(93.05% 0 0);
|
||||
--popover-foreground: oklch(0% 0 0);
|
||||
--primary: oklch(54% 0.1789 271);
|
||||
--primary: oklch(60% 0.13 30);
|
||||
--primary-foreground: oklch(100% 0 0);
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: oklch(0% 0 0);
|
||||
@ -49,7 +49,7 @@
|
||||
--foreground: oklch(92.8% 0 0);
|
||||
--popover: oklch(28.5% 0 0);
|
||||
--popover-foreground: oklch(92.8% 0 0);
|
||||
--primary: oklch(54% 0.1789 271);
|
||||
--primary: oklch(60% 0.13 30);
|
||||
--primary-foreground: oklch(92.8% 0 0);
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: oklch(92.8% 0 0);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
apps/webapp/app/trigger/.gitkeep
Normal file
0
apps/webapp/app/trigger/.gitkeep
Normal file
561
apps/webapp/app/trigger/chat/chat-utils.ts
Normal file
561
apps/webapp/app/trigger/chat/chat-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
apps/webapp/app/trigger/chat/chat.ts
Normal file
136
apps/webapp/app/trigger/chat/chat.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
50
apps/webapp/app/trigger/chat/memory-utils.ts
Normal file
50
apps/webapp/app/trigger/chat/memory-utils.ts
Normal 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" };
|
||||
}
|
||||
};
|
||||
131
apps/webapp/app/trigger/chat/prompt.ts
Normal file
131
apps/webapp/app/trigger/chat/prompt.ts
Normal 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>
|
||||
`;
|
||||
264
apps/webapp/app/trigger/chat/stream-utils.ts
Normal file
264
apps/webapp/app/trigger/chat/stream-utils.ts
Normal 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");
|
||||
}
|
||||
46
apps/webapp/app/trigger/chat/types.ts
Normal file
46
apps/webapp/app/trigger/chat/types.ts
Normal 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 };
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
28
apps/webapp/app/trigger/conversation/prompt.ts
Normal file
28
apps/webapp/app/trigger/conversation/prompt.ts
Normal 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"}`;
|
||||
@ -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,
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
87
apps/webapp/app/trigger/integrations/integration-run.ts
Normal file
87
apps/webapp/app/trigger/integrations/integration-run.ts
Normal 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 : {}),
|
||||
// },
|
||||
// });
|
||||
// },
|
||||
// });
|
||||
64
apps/webapp/app/trigger/integrations/scheduler.ts
Normal file
64
apps/webapp/app/trigger/integrations/scheduler.ts
Normal 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";
|
||||
// },
|
||||
// });
|
||||
151
apps/webapp/app/trigger/utils/mcp.ts
Normal file
151
apps/webapp/app/trigger/utils/mcp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
256
apps/webapp/app/trigger/utils/stdio.ts
Normal file
256
apps/webapp/app/trigger/utils/stdio.ts
Normal 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;
|
||||
}
|
||||
123
apps/webapp/app/trigger/utils/types.ts
Normal file
123
apps/webapp/app/trigger/utils/types.ts
Normal 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[];
|
||||
}
|
||||
544
apps/webapp/app/trigger/utils/utils.ts
Normal file
544
apps/webapp/app/trigger/utils/utils.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
378
apps/webapp/prisma/schema.prisma
Normal file
378
apps/webapp/prisma/schema.prisma
Normal file
@ -0,0 +1,378 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DIRECT_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
previewFeatures = ["tracing"]
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
text String
|
||||
// Used to link the task or activity to external apps
|
||||
sourceURL String?
|
||||
|
||||
integrationAccount IntegrationAccount? @relation(fields: [integrationAccountId], references: [id])
|
||||
integrationAccountId String?
|
||||
|
||||
rejectionReason String?
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
workspaceId String
|
||||
|
||||
WebhookDeliveryLog WebhookDeliveryLog[]
|
||||
|
||||
ConversationHistory ConversationHistory[]
|
||||
}
|
||||
|
||||
model AuthorizationCode {
|
||||
id String @id @default(cuid())
|
||||
|
||||
code String @unique
|
||||
|
||||
personalAccessToken PersonalAccessToken? @relation(fields: [personalAccessTokenId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
personalAccessTokenId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
unread Boolean @default(false)
|
||||
|
||||
title String?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
workspace Workspace? @relation(fields: [workspaceId], references: [id])
|
||||
workspaceId String?
|
||||
|
||||
status String @default("pending") // Can be "pending", "running", "completed", "failed", "need_attention"
|
||||
|
||||
ConversationHistory ConversationHistory[]
|
||||
}
|
||||
|
||||
model ConversationExecutionStep {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
thought String
|
||||
message String
|
||||
|
||||
actionId String?
|
||||
actionOutput String?
|
||||
actionInput String?
|
||||
actionStatus String?
|
||||
|
||||
metadata Json? @default("{}")
|
||||
|
||||
conversationHistory ConversationHistory @relation(fields: [conversationHistoryId], references: [id])
|
||||
conversationHistoryId String
|
||||
}
|
||||
|
||||
model ConversationHistory {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
message String
|
||||
userType UserType
|
||||
|
||||
activity Activity? @relation(fields: [activityId], references: [id])
|
||||
activityId String?
|
||||
|
||||
context Json?
|
||||
|
||||
thoughts Json?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id])
|
||||
conversationId String
|
||||
ConversationExecutionStep ConversationExecutionStep[]
|
||||
}
|
||||
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
name String @unique // e.g., "User", "Issue", "Task", "Automation"
|
||||
metadata Json // Store field definitions and their types
|
||||
|
||||
// Relations
|
||||
spaceEntities SpaceEntity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model IngestionQueue {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relations
|
||||
space Space? @relation(fields: [spaceId], references: [id])
|
||||
spaceId String?
|
||||
|
||||
// Queue metadata
|
||||
data Json // The actual data to be processed
|
||||
output Json? // The processed output data
|
||||
status IngestionStatus
|
||||
priority Int @default(0)
|
||||
|
||||
workspaceId String
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
|
||||
// Error handling
|
||||
error String?
|
||||
retryCount Int @default(0)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
processedAt DateTime?
|
||||
}
|
||||
|
||||
model IntegrationAccount {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
integrationConfiguration Json
|
||||
accountId String?
|
||||
settings Json?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
integratedBy User @relation(references: [id], fields: [integratedById])
|
||||
integratedById String
|
||||
integrationDefinition IntegrationDefinitionV2 @relation(references: [id], fields: [integrationDefinitionId])
|
||||
integrationDefinitionId String
|
||||
workspace Workspace @relation(references: [id], fields: [workspaceId])
|
||||
workspaceId String
|
||||
Activity Activity[]
|
||||
|
||||
@@unique([accountId, integrationDefinitionId, workspaceId])
|
||||
}
|
||||
|
||||
model IntegrationDefinitionV2 {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
name String @unique
|
||||
slug String
|
||||
description String
|
||||
icon String
|
||||
config Json?
|
||||
spec Json @default("{}")
|
||||
version String?
|
||||
url String?
|
||||
|
||||
workspace Workspace? @relation(references: [id], fields: [workspaceId])
|
||||
workspaceId String?
|
||||
|
||||
IntegrationAccount IntegrationAccount[]
|
||||
}
|
||||
|
||||
model InvitationCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
|
||||
users User[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model PersonalAccessToken {
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// If generated by the CLI this will be "cli", otherwise user-provided
|
||||
name String
|
||||
|
||||
/// This is the token encrypted using the ENCRYPTION_KEY
|
||||
encryptedToken Json
|
||||
|
||||
/// This is shown in the UI, with ********
|
||||
obfuscatedToken String
|
||||
|
||||
/// This is used to find the token in the database
|
||||
hashedToken String @unique
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
revokedAt DateTime?
|
||||
lastAccessedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
authorizationCodes AuthorizationCode[]
|
||||
}
|
||||
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
autoMode Boolean @default(false)
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
// Space's enabled entities
|
||||
enabledEntities SpaceEntity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
IngestionQueue IngestionQueue[]
|
||||
}
|
||||
|
||||
model SpaceEntity {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relations
|
||||
space Space @relation(fields: [spaceId], references: [id])
|
||||
spaceId String
|
||||
|
||||
entity Entity @relation(fields: [entityId], references: [id])
|
||||
entityId String
|
||||
|
||||
// Custom settings for this entity in this space
|
||||
settings Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([spaceId, entityId])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
|
||||
authenticationMethod AuthenticationMethod
|
||||
authenticationProfile Json?
|
||||
authenticationExtraParams Json?
|
||||
authIdentifier String? @unique
|
||||
|
||||
displayName String?
|
||||
name String?
|
||||
avatarUrl String?
|
||||
|
||||
memoryFilter String? // Adding memory filter instructions
|
||||
|
||||
admin Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
marketingEmails Boolean @default(true)
|
||||
confirmedBasicDetails Boolean @default(false)
|
||||
|
||||
referralSource String?
|
||||
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
InvitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
||||
invitationCodeId String?
|
||||
Space Space[]
|
||||
Workspace Workspace?
|
||||
IntegrationAccount IntegrationAccount[]
|
||||
WebhookConfiguration WebhookConfiguration[]
|
||||
Conversation Conversation[]
|
||||
ConversationHistory ConversationHistory[]
|
||||
}
|
||||
|
||||
model WebhookConfiguration {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
secret String?
|
||||
isActive Boolean @default(true)
|
||||
eventTypes String[] // List of event types this webhook is interested in, e.g. ["activity.created"]
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
workspace Workspace? @relation(fields: [workspaceId], references: [id])
|
||||
workspaceId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
WebhookDeliveryLog WebhookDeliveryLog[]
|
||||
}
|
||||
|
||||
model WebhookDeliveryLog {
|
||||
id String @id @default(cuid())
|
||||
webhookConfiguration WebhookConfiguration @relation(fields: [webhookConfigurationId], references: [id])
|
||||
webhookConfigurationId String
|
||||
|
||||
activity Activity? @relation(fields: [activityId], references: [id])
|
||||
activityId String?
|
||||
|
||||
status WebhookDeliveryStatus
|
||||
responseStatusCode Int?
|
||||
responseBody String?
|
||||
error String?
|
||||
deliveredAt DateTime @default(now())
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted DateTime?
|
||||
|
||||
name String
|
||||
slug String @unique
|
||||
icon String?
|
||||
|
||||
integrations String[]
|
||||
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
IngestionQueue IngestionQueue[]
|
||||
IntegrationAccount IntegrationAccount[]
|
||||
IntegrationDefinitionV2 IntegrationDefinitionV2[]
|
||||
Activity Activity[]
|
||||
WebhookConfiguration WebhookConfiguration[]
|
||||
Conversation Conversation[]
|
||||
}
|
||||
|
||||
enum AuthenticationMethod {
|
||||
GOOGLE
|
||||
MAGIC_LINK
|
||||
}
|
||||
|
||||
enum IngestionStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum UserType {
|
||||
Agent
|
||||
User
|
||||
System
|
||||
}
|
||||
|
||||
enum WebhookDeliveryStatus {
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
38
apps/webapp/trigger.config.ts
Normal file
38
apps/webapp/trigger.config.ts
Normal 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",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
@ -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/*"],
|
||||
|
||||
@ -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
21
core/types/package.json
Normal 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
1
core/types/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './integration';
|
||||
64
core/types/src/integration.ts
Normal file
64
core/types/src/integration.ts
Normal 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
18
core/types/tsconfig.json
Normal 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
Loading…
x
Reference in New Issue
Block a user