mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 23:48:26 +00:00
Feat: added integration connect and mcp oAuth (#20)
* Feat: added integration connect and mcp oAuth * Feat: add mcp support to chat * Fix: UI for integrations and logs * Fix: ui * Fix: proxy server * Feat: enhance MCP tool integration and loading functionality * Fix: added header * Fix: Linear integration sync --------- Co-authored-by: Manoj K <saimanoj58@gmail.com>
This commit is contained in:
parent
ff8fd1c985
commit
038acea669
137
apps/webapp/app/components/common/page-header.tsx
Normal file
137
apps/webapp/app/components/common/page-header.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { SidebarTrigger } from "~/components/ui/sidebar";
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface PageHeaderAction {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "secondary" | "outline" | "ghost";
|
||||
}
|
||||
|
||||
export interface PageHeaderTab {
|
||||
label: string;
|
||||
value: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
actions?: PageHeaderAction[];
|
||||
tabs?: PageHeaderTab[];
|
||||
showBackForward?: boolean;
|
||||
}
|
||||
|
||||
// Back and Forward navigation component
|
||||
function NavigationBackForward() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Back"
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Forward"
|
||||
onClick={() => navigate(1)}
|
||||
className="rounded"
|
||||
type="button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
breadcrumbs,
|
||||
actions,
|
||||
tabs,
|
||||
showBackForward = true,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
|
||||
<div className="-ml-1 flex items-center gap-1">
|
||||
{/* Back/Forward navigation before SidebarTrigger */}
|
||||
{showBackForward && <NavigationBackForward />}
|
||||
<SidebarTrigger className="mr-1" />
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<nav className="mt-0.5 flex items-center space-x-1">
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<span className="text-muted-foreground mx-1">/</span>
|
||||
)}
|
||||
{breadcrumb.href ? (
|
||||
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
||||
) : (
|
||||
<span className="text-gray-900">{breadcrumb.label}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<h1 className="text-base">{title}</h1>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs && tabs.length > 0 && (
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded"
|
||||
isActive={tab.isActive}
|
||||
onClick={tab.onClick}
|
||||
aria-current={tab.isActive ? "page" : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant || "secondary"}
|
||||
className="gap-2"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { UserTypeEnum } from "@core/types";
|
||||
import { type ConversationHistory } from "@core/database";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { extensionsForConversation } from "./editor-extensions";
|
||||
import { skillExtension } from "../editor/skill-extension";
|
||||
|
||||
interface AIConversationItemProps {
|
||||
conversationHistory: ConversationHistory;
|
||||
@ -20,7 +21,7 @@ export const ConversationItem = ({
|
||||
const id = `a${conversationHistory.id.replace(/-/g, "")}`;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [...extensionsForConversation],
|
||||
extensions: [...extensionsForConversation, skillExtension],
|
||||
editable: false,
|
||||
content: conversationHistory.message,
|
||||
});
|
||||
|
||||
@ -142,7 +142,7 @@ export const ConversationList = ({
|
||||
className={cn(
|
||||
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
|
||||
currentConversationId === conversation.id &&
|
||||
"bg-accent text-accent-foreground font-semibold",
|
||||
"bg-accent font-semibold",
|
||||
)}
|
||||
onClick={() => {
|
||||
navigate(`/home/conversation/${conversation.id}`);
|
||||
@ -155,7 +155,7 @@ export const ConversationList = ({
|
||||
<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")}>
|
||||
<p className={cn("text-foreground truncate font-normal")}>
|
||||
{conversation.title || "Untitled Conversation"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -8,11 +8,6 @@ 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";
|
||||
|
||||
export const ConversationNew = ({
|
||||
user,
|
||||
@ -45,106 +40,96 @@ export const ConversationNew = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="rounded-md">
|
||||
<ResizableHandle className="w-1" />
|
||||
<Form
|
||||
action="/home/conversation"
|
||||
method="post"
|
||||
onSubmit={(e) => submitForm(e)}
|
||||
className="h-[calc(100vh_-_56px)] pt-2"
|
||||
>
|
||||
<div className={cn("flex h-[calc(100vh_-_56px)] 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 text-left text-[32px] font-medium">
|
||||
Hello <span className="text-primary">{user.name}</span>
|
||||
</h1>
|
||||
|
||||
<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_-_56px)] 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 text-left text-[32px] font-medium">
|
||||
Hello <span className="text-primary">{user.name}</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-1 mb-4">
|
||||
Demo UI: basic conversation to showcase memory integration.
|
||||
</p>
|
||||
<div className="bg-background-3 rounded-lg border-1 border-gray-300 py-2">
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
ref={editorRef}
|
||||
autofocus
|
||||
extensions={[
|
||||
Placeholder.configure({
|
||||
placeholder: () => {
|
||||
return "Ask CORE ...";
|
||||
},
|
||||
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();
|
||||
|
||||
<p className="text-muted-foreground mx-1 mb-4">
|
||||
Demo UI: basic conversation to showcase memory integration.
|
||||
</p>
|
||||
<div className="bg-background-3 rounded-lg border-1 border-gray-300 py-2">
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
ref={editorRef}
|
||||
autofocus
|
||||
extensions={[
|
||||
Placeholder.configure({
|
||||
placeholder: () => {
|
||||
return "Ask CORE ...";
|
||||
},
|
||||
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",
|
||||
},
|
||||
);
|
||||
|
||||
if (content) {
|
||||
submit(
|
||||
{ message: content, title: content },
|
||||
{
|
||||
action: "/home/conversation",
|
||||
method: "post",
|
||||
},
|
||||
);
|
||||
|
||||
setContent("");
|
||||
setTitle("");
|
||||
}
|
||||
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();
|
||||
const text = editor.getText();
|
||||
setContent(html);
|
||||
setTitle(text);
|
||||
}}
|
||||
/>
|
||||
</EditorRoot>
|
||||
<div className="mb-1 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>
|
||||
setContent("");
|
||||
setTitle("");
|
||||
}
|
||||
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();
|
||||
const text = editor.getText();
|
||||
setContent(html);
|
||||
setTitle(text);
|
||||
}}
|
||||
/>
|
||||
</EditorRoot>
|
||||
<div className="mb-1 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>
|
||||
</Form>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
14
apps/webapp/app/components/editor/conversation-context.tsx
Normal file
14
apps/webapp/app/components/editor/conversation-context.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
interface ConversationContextInterface {
|
||||
conversationHistoryId: string;
|
||||
|
||||
// Used just in streaming
|
||||
streaming?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
actionMessages?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const ConversationContext = React.createContext<
|
||||
ConversationContextInterface | undefined
|
||||
>(undefined);
|
||||
@ -0,0 +1 @@
|
||||
export * from './skill-extension';
|
||||
@ -0,0 +1,58 @@
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { getIcon as iconUtil, type IconType } from "../../icon-utils";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const SkillComponent = (props: any) => {
|
||||
const id = props.node.attrs.id;
|
||||
const name = props.node.attrs.name;
|
||||
const agent = props.node.attrs.agent;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
if (id === "undefined" || id === undefined || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
const Icon = iconUtil(agent as IconType);
|
||||
|
||||
return <Icon size={18} className="rounded-sm" />;
|
||||
};
|
||||
|
||||
const snakeToTitleCase = (input: string): string => {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const words = input.split("_");
|
||||
|
||||
// First word: capitalize first letter
|
||||
const firstWord =
|
||||
words[0].charAt(0).toUpperCase() + words[0].slice(1).toLowerCase();
|
||||
|
||||
// Rest of the words: all lowercase
|
||||
const restWords = words.slice(1).map((word) => word.toLowerCase());
|
||||
|
||||
// Join with spaces
|
||||
return [firstWord, ...restWords].join(" ");
|
||||
};
|
||||
|
||||
const getComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="bg-grayAlpha-100 text-sm-md mt-0.5 flex w-fit items-center gap-2 rounded p-2">
|
||||
{getIcon()}
|
||||
<span className="font-mono text-sm">{snakeToTitleCase(name)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="inline w-fit">{getComponent()}</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { ReactNodeViewRenderer, Node, mergeAttributes } from "@tiptap/react";
|
||||
|
||||
import { SkillComponent } from "./skill-component";
|
||||
|
||||
export const skillExtension = Node.create({
|
||||
name: "skill",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
name: {
|
||||
default: undefined,
|
||||
},
|
||||
agent: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "skill",
|
||||
getAttrs: (element) => {
|
||||
return {
|
||||
id: element.getAttribute("id"),
|
||||
name: element.getAttribute("name"),
|
||||
agent: element.getAttribute("agent"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["skill", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(SkillComponent);
|
||||
},
|
||||
});
|
||||
@ -5,14 +5,16 @@ import {
|
||||
RiSlackFill,
|
||||
} from "@remixicon/react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { LinearIcon, SlackIcon } from "./icons";
|
||||
|
||||
export const ICON_MAPPING = {
|
||||
slack: RiSlackFill,
|
||||
slack: SlackIcon,
|
||||
email: RiMailFill,
|
||||
discord: RiDiscordFill,
|
||||
github: RiGithubFill,
|
||||
|
||||
gmail: RiMailFill,
|
||||
linear: LinearIcon,
|
||||
|
||||
// Default icon
|
||||
integration: LayoutGrid,
|
||||
|
||||
2
apps/webapp/app/components/icons/index.ts
Normal file
2
apps/webapp/app/components/icons/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./slack-icon";
|
||||
export * from "./linear-icon";
|
||||
23
apps/webapp/app/components/icons/linear-icon.tsx
Normal file
23
apps/webapp/app/components/icons/linear-icon.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function LinearIcon({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 256 255.999966"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
>
|
||||
<title>Linear</title>
|
||||
<g>
|
||||
<path
|
||||
d="M8.17351507,102.61269 L153.38731,247.826434 C155.506492,249.945871 154.484163,253.546047 151.537796,254.096394 C146.624112,255.014493 141.611832,255.651912 136.517857,255.991749 C135.430224,256.064224 134.367944,255.654216 133.5971,254.883372 L1.11657404,122.4029 C0.345783548,121.632056 -0.0643290124,120.569776 0.0082341699,119.482143 C0.348078154,114.388168 0.985591977,109.375888 1.9035369,104.462204 C2.45395044,101.515837 6.05412868,100.493508 8.17351507,102.61269 Z M4.08163806,161.409157 C3.11270474,157.795152 7.38226007,155.515399 10.0279718,158.161111 L97.8388892,245.972054 C100.484601,248.617766 98.2048481,252.887372 94.5908433,251.918311 C50.5602697,240.113381 15.8867212,205.43973 4.08163806,161.409157 Z M16.8085843,64.1644626 C18.0417047,62.028635 20.9559826,61.7013463 22.6999089,63.4453494 L192.554651,233.299989 C194.298654,235.043992 193.971621,237.958347 191.835537,239.191441 C188.24202,241.266062 184.538382,243.171405 180.73538,244.896457 C179.304068,245.545401 177.625881,245.218112 176.514687,244.106918 L11.8930308,79.4853127 C10.7818114,78.3741189 10.4544971,76.695932 11.1036452,75.2646202 C12.828595,71.4616177 14.7338864,67.7579799 16.8085843,64.1644626 Z M127.860072,0 C198.629979,0 256,57.3702771 256,128.139928 C256,165.709238 239.831733,199.502437 214.07401,222.940712 C212.587126,224.293659 210.305837,224.204026 208.884257,222.782702 L33.2172979,47.1157434 C31.7959738,45.6941631 31.7063407,43.4128738 33.0592877,41.9259895 C56.4975633,16.1681897 90.2907616,0 127.860072,0 Z"
|
||||
fill="#222326"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
apps/webapp/app/components/icons/slack-icon.tsx
Normal file
30
apps/webapp/app/components/icons/slack-icon.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function SlackIcon({ size = 18, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 127 127"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
6
apps/webapp/app/components/icons/types.tsx
Normal file
6
apps/webapp/app/components/icons/types.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
export interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
color?: string;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
}
|
||||
@ -1,248 +0,0 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { FormButtons } from "~/components/ui/FormButtons";
|
||||
|
||||
interface IntegrationAuthDialogProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
spec: any;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function parseSpec(spec: any) {
|
||||
if (!spec) return {};
|
||||
if (typeof spec === "string") {
|
||||
try {
|
||||
return JSON.parse(spec);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function IntegrationAuthDialog({
|
||||
integration,
|
||||
children,
|
||||
onOpenChange,
|
||||
}: IntegrationAuthDialogProps) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
|
||||
|
||||
const apiKeyFetcher = useFetcher();
|
||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||
const mcpFetcher = useFetcher<{ redirectURL: string }>();
|
||||
|
||||
const specData = parseSpec(integration.spec);
|
||||
const hasApiKey = !!specData?.auth?.api_key;
|
||||
const hasOAuth2 = !!specData?.auth?.OAuth2;
|
||||
const hasMCPAuth = !!specData?.mcpAuth;
|
||||
|
||||
const handleApiKeyConnect = useCallback(() => {
|
||||
if (!apiKey.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
apiKeyFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, apiKey, apiKeyFetcher]);
|
||||
|
||||
const handleOAuthConnect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
oauthFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, oauthFetcher]);
|
||||
|
||||
const handleMCPConnect = useCallback(() => {
|
||||
setIsMCPConnecting(true);
|
||||
mcpFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
mcp: true,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, mcpFetcher]);
|
||||
|
||||
// Watch for fetcher completion
|
||||
React.useEffect(() => {
|
||||
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||
if (apiKeyFetcher.data !== undefined) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (oauthFetcher.state === "idle" && isConnecting) {
|
||||
if (oauthFetcher.data?.redirectURL) {
|
||||
window.location.href = oauthFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mcpFetcher.state === "idle" && isMCPConnecting) {
|
||||
if (mcpFetcher.data?.redirectURL) {
|
||||
window.location.href = mcpFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsMCPConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setApiKey("");
|
||||
}
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="p-4 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect to {integration.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{integration.description ||
|
||||
`Connect your ${integration.name} account to enable integration.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* API Key Authentication */}
|
||||
{hasApiKey && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
{specData?.auth?.api_key?.label || "API Key"}
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
placeholder="Enter your API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{specData?.auth?.api_key?.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{specData.auth.api_key.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
onClick={handleApiKeyConnect}
|
||||
>
|
||||
{isLoading || apiKeyFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Authentication */}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isConnecting || oauthFetcher.state === "submitting"}
|
||||
onClick={handleOAuthConnect}
|
||||
>
|
||||
{isConnecting || oauthFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MCP Authentication */}
|
||||
{hasMCPAuth && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-medium mb-2">MCP Authentication</h4>
|
||||
<p className="text-muted-foreground text-xs mb-4">
|
||||
This integration requires MCP (Model Context Protocol) authentication.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
|
||||
onClick={handleMCPConnect}
|
||||
>
|
||||
{isMCPConnecting || mcpFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect via MCP`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No authentication method found */}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
<div className="text-muted-foreground py-4 text-center">
|
||||
This integration doesn't specify an authentication method.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<div className="text-muted-foreground w-full text-xs">
|
||||
By connecting, you agree to the {integration.name} terms of service.
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
109
apps/webapp/app/components/integrations/api-key-auth-section.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
interface ApiKeyAuthSectionProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
specData: any;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function ApiKeyAuthSection({
|
||||
integration,
|
||||
specData,
|
||||
activeAccount,
|
||||
}: ApiKeyAuthSectionProps) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
|
||||
const apiKeyFetcher = useFetcher();
|
||||
|
||||
const handleApiKeyConnect = useCallback(() => {
|
||||
if (!apiKey.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
apiKeyFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, apiKey, apiKeyFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (apiKeyFetcher.state === "idle" && isLoading) {
|
||||
if (apiKeyFetcher.data !== undefined) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
|
||||
|
||||
if (activeAccount || !specData?.auth?.api_key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||
<h4 className="font-medium">API Key Authentication</h4>
|
||||
{!showApiKeyForm ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowApiKeyForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
Connect with API Key
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium">
|
||||
{specData?.auth?.api_key?.label || "API Key"}
|
||||
</label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
placeholder="Enter your API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
{specData?.auth?.api_key?.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{specData.auth.api_key.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowApiKeyForm(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={isLoading || !apiKey.trim()}
|
||||
onClick={handleApiKeyConnect}
|
||||
>
|
||||
{isLoading || apiKeyFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface ConnectedAccountSectionProps {
|
||||
activeAccount?: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ConnectedAccountSection({
|
||||
activeAccount,
|
||||
}: ConnectedAccountSectionProps) {
|
||||
const disconnectFetcher = useFetcher();
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
if (!activeAccount?.id) return;
|
||||
|
||||
disconnectFetcher.submit(
|
||||
{
|
||||
integrationAccountId: activeAccount.id,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account/disconnect",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [activeAccount?.id, disconnectFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disconnectFetcher.state === "idle" && disconnectFetcher.data) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [disconnectFetcher.state, disconnectFetcher.data]);
|
||||
|
||||
if (!activeAccount) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h3 className="text-lg font-medium">Connected Account</h3>
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<div className="text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Check size={16} /> Account ID: {activeAccount.id}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
Connected on{" "}
|
||||
{new Date(activeAccount.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={disconnectFetcher.state === "submitting"}
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
{disconnectFetcher.state === "submitting"
|
||||
? "Disconnecting..."
|
||||
: "Disconnect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
interface IngestionRuleSectionProps {
|
||||
ingestionRule: {
|
||||
id: string;
|
||||
text: string;
|
||||
} | null;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function IngestionRuleSection({
|
||||
ingestionRule,
|
||||
activeAccount,
|
||||
}: IngestionRuleSectionProps) {
|
||||
const [ingestionRuleText, setIngestionRuleText] = useState(
|
||||
ingestionRule?.text || "",
|
||||
);
|
||||
const ingestionRuleFetcher = useFetcher();
|
||||
|
||||
const handleIngestionRuleUpdate = useCallback(() => {
|
||||
ingestionRuleFetcher.submit(
|
||||
{
|
||||
ingestionRule: ingestionRuleText,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
},
|
||||
);
|
||||
}, [ingestionRuleText, ingestionRuleFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ingestionRuleFetcher.state === "idle") {
|
||||
// Optionally show success message or refresh
|
||||
}
|
||||
}, [ingestionRuleFetcher.state, ingestionRuleFetcher.data]);
|
||||
|
||||
if (!activeAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Ingestion Rule</h3>
|
||||
<div className="bg-background-3 space-y-4 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="ingestionRule" className="text-sm font-medium">
|
||||
Rule Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="ingestionRule"
|
||||
placeholder={`Example for Gmail: "Only ingest emails from the last 24 hours that contain the word 'urgent' or 'important' in the subject line or body. Skip promotional emails and newsletters. Focus on emails from known contacts or business domains."`}
|
||||
value={ingestionRuleText}
|
||||
onChange={(e) => setIngestionRuleText(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Describe what data should be ingested from this integration
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!ingestionRuleText.trim() ||
|
||||
ingestionRuleFetcher.state === "submitting"
|
||||
}
|
||||
onClick={handleIngestionRuleUpdate}
|
||||
>
|
||||
{ingestionRuleFetcher.state === "submitting"
|
||||
? "Updating..."
|
||||
: "Update Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface IntegrationCardProps {
|
||||
integration: {
|
||||
@ -19,26 +20,20 @@ interface IntegrationCardProps {
|
||||
slug?: string;
|
||||
};
|
||||
isConnected: boolean;
|
||||
onClick?: () => void;
|
||||
showDetail?: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationCard({
|
||||
integration,
|
||||
isConnected,
|
||||
onClick,
|
||||
showDetail = false,
|
||||
}: IntegrationCardProps) {
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
const CardWrapper = showDetail ? Link : "div";
|
||||
const cardProps = showDetail
|
||||
? { to: `/home/integration/${integration.slug || integration.id}` }
|
||||
: { onClick, className: "cursor-pointer" };
|
||||
|
||||
return (
|
||||
<CardWrapper {...cardProps}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<Link
|
||||
to={`/home/integration/${integration.slug || integration.id}`}
|
||||
className="bg-background-3 h-full rounded-lg"
|
||||
>
|
||||
<Card className="transition-all">
|
||||
<CardHeader className="p-4">
|
||||
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
|
||||
<Component size={18} />
|
||||
@ -51,13 +46,13 @@ export function IntegrationCard({
|
||||
{isConnected && (
|
||||
<CardFooter className="p-3">
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
|
||||
Connected
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { IntegrationCard } from "./IntegrationCard";
|
||||
import { IntegrationAuthDialog } from "./IntegrationAuthDialog";
|
||||
import { IntegrationCard } from "./integration-card";
|
||||
|
||||
interface IntegrationGridProps {
|
||||
integrations: Array<{
|
||||
@ -19,7 +18,6 @@ interface IntegrationGridProps {
|
||||
export function IntegrationGrid({
|
||||
integrations,
|
||||
activeAccountIds,
|
||||
showDetail = false,
|
||||
}: IntegrationGridProps) {
|
||||
const hasActiveAccount = (integrationDefinitionId: string) =>
|
||||
activeAccountIds.has(integrationDefinitionId);
|
||||
@ -34,33 +32,17 @@ export function IntegrationGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{integrations.map((integration) => {
|
||||
const isConnected = hasActiveAccount(integration.id);
|
||||
|
||||
if (showDetail) {
|
||||
return (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
isConnected={isConnected}
|
||||
showDetail={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationAuthDialog
|
||||
key={integration.id}
|
||||
<IntegrationCard
|
||||
integration={integration}
|
||||
>
|
||||
<IntegrationCard
|
||||
integration={integration}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
</IntegrationAuthDialog>
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
133
apps/webapp/app/components/integrations/mcp-auth-section.tsx
Normal file
133
apps/webapp/app/components/integrations/mcp-auth-section.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface MCPAuthSectionProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
activeAccount?: {
|
||||
id: string;
|
||||
integrationConfiguration?: {
|
||||
mcp?: any;
|
||||
};
|
||||
};
|
||||
hasMCPAuth: boolean;
|
||||
}
|
||||
|
||||
export function MCPAuthSection({
|
||||
integration,
|
||||
activeAccount,
|
||||
hasMCPAuth,
|
||||
}: MCPAuthSectionProps) {
|
||||
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
|
||||
const mcpFetcher = useFetcher<{ redirectURL: string }>();
|
||||
const disconnectMcpFetcher = useFetcher();
|
||||
|
||||
const isMCPConnected = activeAccount?.integrationConfiguration?.mcp;
|
||||
|
||||
const handleMCPConnect = useCallback(() => {
|
||||
setIsMCPConnecting(true);
|
||||
mcpFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
integrationAccountId: activeAccount?.id as string,
|
||||
mcp: true,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, mcpFetcher]);
|
||||
|
||||
const handleMCPDisconnect = useCallback(() => {
|
||||
if (!activeAccount?.id) return;
|
||||
|
||||
disconnectMcpFetcher.submit(
|
||||
{
|
||||
integrationAccountId: activeAccount.id,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/integration_account/disconnect_mcp",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [activeAccount?.id, disconnectMcpFetcher]);
|
||||
|
||||
// Watch for fetcher completion
|
||||
React.useEffect(() => {
|
||||
if (mcpFetcher.state === "idle" && isMCPConnecting) {
|
||||
if (mcpFetcher.data?.redirectURL) {
|
||||
window.location.href = mcpFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsMCPConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disconnectMcpFetcher.state === "idle" && disconnectMcpFetcher.data) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [disconnectMcpFetcher.state, disconnectMcpFetcher.data]);
|
||||
|
||||
if (!hasMCPAuth || !activeAccount) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-2">
|
||||
<h3 className="text-lg font-medium">MCP Authentication</h3>
|
||||
|
||||
{isMCPConnected ? (
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<div className="text-sm">
|
||||
<p className="inline-flex items-center gap-2 font-medium">
|
||||
<Check size={16} /> MCP Connected
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
MCP (Model Context Protocol) authentication is active
|
||||
</p>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={disconnectMcpFetcher.state === "submitting"}
|
||||
onClick={handleMCPDisconnect}
|
||||
>
|
||||
{disconnectMcpFetcher.state === "submitting"
|
||||
? "Disconnecting..."
|
||||
: "Disconnect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeAccount ? (
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<h4 className="text-md mb-1 font-medium">
|
||||
MCP (Model Context Protocol) Authentication
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3 text-sm">
|
||||
This integration requires MCP (Model Context Protocol)
|
||||
authentication. Please provide the required MCP credentials in
|
||||
addition to any other authentication method.
|
||||
</p>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
|
||||
onClick={handleMCPConnect}
|
||||
>
|
||||
{isMCPConnecting || mcpFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect for MCP`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface OAuthAuthSectionProps {
|
||||
integration: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
specData: any;
|
||||
activeAccount: any;
|
||||
}
|
||||
|
||||
export function OAuthAuthSection({
|
||||
integration,
|
||||
specData,
|
||||
activeAccount,
|
||||
}: OAuthAuthSectionProps) {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const oauthFetcher = useFetcher<{ redirectURL: string }>();
|
||||
|
||||
const handleOAuthConnect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
oauthFetcher.submit(
|
||||
{
|
||||
integrationDefinitionId: integration.id,
|
||||
redirectURL: window.location.href,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
action: "/api/v1/oauth",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}, [integration.id, oauthFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (oauthFetcher.state === "idle" && isConnecting) {
|
||||
if (oauthFetcher.data?.redirectURL) {
|
||||
window.location.href = oauthFetcher.data.redirectURL;
|
||||
} else {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
|
||||
|
||||
if (activeAccount || !specData?.auth?.OAuth2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-3 rounded-lg p-4">
|
||||
<h4 className="mb-3 font-medium">OAuth 2.0 Authentication</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={isConnecting || oauthFetcher.state === "submitting"}
|
||||
onClick={handleOAuthConnect}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting || oauthFetcher.state === "submitting"
|
||||
? "Connecting..."
|
||||
: `Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/webapp/app/components/integrations/section.tsx
Normal file
33
apps/webapp/app/components/integrations/section.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
interface SectionProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
metadata?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
children,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<div className="flex h-full gap-6">
|
||||
<div className="flex w-[400px] shrink-0 flex-col">
|
||||
{icon && <>{icon}</>}
|
||||
<h3 className="text-lg"> {title} </h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
{metadata ? metadata : null}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="flex h-full w-full justify-end overflow-auto">
|
||||
<div className="flex h-full max-w-[76ch] grow flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
List,
|
||||
InfiniteLoader,
|
||||
WindowScroller,
|
||||
AutoSizer,
|
||||
CellMeasurer,
|
||||
CellMeasurerCache,
|
||||
@ -12,10 +10,97 @@ import {
|
||||
import { type LogItem } from "~/hooks/use-logs";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { AlertCircle, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ScrollManagedList } from "../virtualized-list";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Button } from "../ui";
|
||||
|
||||
// --- LogTextCollapse component ---
|
||||
function LogTextCollapse({ text, error }: { text?: string; error?: string }) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
// Show collapse if text is long (by word count)
|
||||
const COLLAPSE_WORD_LIMIT = 30;
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<div className="text-muted-foreground mb-2 text-xs italic">
|
||||
No log details.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split by words for word count
|
||||
const words = text.split(/\s+/);
|
||||
const isLong = words.length > COLLAPSE_WORD_LIMIT;
|
||||
|
||||
let displayText: string;
|
||||
if (isLong) {
|
||||
displayText = words.slice(0, COLLAPSE_WORD_LIMIT).join(" ") + " ...";
|
||||
} else {
|
||||
displayText = text;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-p-wrap pt-2 text-sm break-words",
|
||||
isLong ? "max-h-16 overflow-hidden" : "",
|
||||
)}
|
||||
style={{ lineHeight: "1.5" }}
|
||||
>
|
||||
{displayText}
|
||||
</p>
|
||||
{isLong && (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex w-full items-center justify-between">
|
||||
<span>Log Details</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[70vh] overflow-auto p-0">
|
||||
<p
|
||||
className="px-3 py-2 text-sm break-words whitespace-pre-wrap"
|
||||
style={{ lineHeight: "1.5" }}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center justify-end text-xs",
|
||||
isLong && "justify-between",
|
||||
)}
|
||||
>
|
||||
{isLong && (
|
||||
<Button variant="ghost" size="sm" className="-ml-2 rounded">
|
||||
See full
|
||||
</Button>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface VirtualLogsListProps {
|
||||
logs: LogItem[];
|
||||
hasMore: boolean;
|
||||
@ -48,37 +133,20 @@ function LogItemRenderer(
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
||||
case "PENDING":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case "FAILED":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "CANCELLED":
|
||||
return <XCircle className="h-4 w-4 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
|
||||
case "COMPLETED":
|
||||
return "bg-green-100 text-green-800";
|
||||
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
|
||||
case "FAILED":
|
||||
return "bg-red-100 text-red-800";
|
||||
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
|
||||
case "CANCELLED":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
@ -95,13 +163,18 @@ function LogItemRenderer(
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="secondary" className="rounded text-xs">
|
||||
{log.source}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
{getStatusIcon(log.status)}
|
||||
<Badge className={cn("text-xs", getStatusColor(log.status))}>
|
||||
{log.status.toLowerCase()}
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded text-xs",
|
||||
getStatusColor(log.status),
|
||||
)}
|
||||
>
|
||||
{log.status.charAt(0).toUpperCase() +
|
||||
log.status.slice(1).toLowerCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@ -110,38 +183,7 @@ function LogItemRenderer(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-gray-700">{log.ingestText}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-4">
|
||||
{log.sourceURL && (
|
||||
<a
|
||||
href={log.sourceURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
Source URL
|
||||
</a>
|
||||
)}
|
||||
{log.processedAt && (
|
||||
<span>
|
||||
Processed: {new Date(log.processedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{log.error && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate" title={log.error}>
|
||||
{log.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LogTextCollapse text={log.ingestText} error={log.error} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -52,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div className="mt-1 flex w-full items-center justify-start gap-2">
|
||||
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
|
||||
<Logo width={20} height={20} />
|
||||
C.O.R.E.
|
||||
</div>
|
||||
|
||||
@ -30,7 +30,7 @@ export const NavMain = ({
|
||||
<Button
|
||||
isActive={location.pathname.includes(item.url)}
|
||||
className={cn(
|
||||
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
|
||||
"bg-grayAlpha-100 text-foreground w-fit gap-1 !rounded-md",
|
||||
location.pathname.includes(item.url) &&
|
||||
"!bg-accent !text-accent-foreground",
|
||||
)}
|
||||
|
||||
@ -8,10 +8,10 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-none bg-grayAlpha-100",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground bg-background",
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import React from 'react';
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "@radix-ui/react-icons";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'checkbox peer h-4 w-4 shrink-0 rounded-sm border-1 border-border-dark focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
|
||||
"checkbox peer border-border-dark focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-[5px] border-1 focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center text-white justify-center')}
|
||||
className={cn("flex items-center justify-center text-white")}
|
||||
>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { useLocation, useNavigate } from "@remix-run/react";
|
||||
import { Button } from "./button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Plus } from "lucide-react";
|
||||
import { SidebarTrigger } from "./sidebar";
|
||||
import React from "react";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
"/home/dashboard": "Memory graph",
|
||||
"/home/conversation": "Conversation",
|
||||
"/home/integrations": "Integrations",
|
||||
"/home/integration": "Integrations",
|
||||
"/home/logs": "Logs",
|
||||
};
|
||||
|
||||
@ -50,6 +50,36 @@ function getLogsTab(pathname: string): "all" | "activity" {
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Back and Forward navigation component
|
||||
function NavigationBackForward() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Back"
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Forward"
|
||||
onClick={() => navigate(1)}
|
||||
className="rounded"
|
||||
type="button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@ -72,8 +102,10 @@ export function SiteHeader() {
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="-ml-1 flex items-center gap-1">
|
||||
{/* Back/Forward navigation before SidebarTrigger */}
|
||||
<NavigationBackForward />
|
||||
<SidebarTrigger className="mr-1" />
|
||||
|
||||
<h1 className="text-base">{title}</h1>
|
||||
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await requireUserId(request);
|
||||
const body = await request.json();
|
||||
const { integrationAccountId } = body;
|
||||
|
||||
if (!integrationAccountId) {
|
||||
return json(
|
||||
{ error: "Integration account ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete the integration account by setting deletedAt
|
||||
const updatedAccount = await prisma.integrationAccount.delete({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
deleted: null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Integration account disconnected (soft deleted)", {
|
||||
integrationAccountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: "Integration account disconnected successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to disconnect integration account", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to disconnect integration account" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await requireUserId(request);
|
||||
const body = await request.json();
|
||||
const { integrationAccountId } = body;
|
||||
|
||||
if (!integrationAccountId) {
|
||||
return json(
|
||||
{ error: "Integration account ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current integration account
|
||||
const currentAccount = await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
deleted: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentAccount) {
|
||||
return json({ error: "Integration account not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the current configuration
|
||||
const currentConfig =
|
||||
(currentAccount.integrationConfiguration as any) || {};
|
||||
|
||||
// Remove the mcp key from the configuration
|
||||
const updatedConfig = { ...currentConfig };
|
||||
delete updatedConfig.mcp;
|
||||
|
||||
// Update the integration account
|
||||
const updatedAccount = await prisma.integrationAccount.update({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
deleted: null,
|
||||
},
|
||||
data: {
|
||||
integrationConfiguration: updatedConfig,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("MCP configuration disconnected", {
|
||||
integrationAccountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: "MCP configuration disconnected successfully",
|
||||
account: updatedAccount,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to disconnect MCP configuration", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: "Failed to disconnect MCP configuration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { createIntegrationAccount } from "~/services/integrationAccount.server";
|
||||
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { IntegrationEventType } from "@core/types";
|
||||
import { runIntegrationTrigger } from "~/services/integration.server";
|
||||
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { getWorkspaceByUser } from "~/models/workspace.server";
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import { scheduler } from "~/trigger/integrations/scheduler";
|
||||
|
||||
// Schema for creating an integration account with API key
|
||||
const IntegrationAccountBodySchema = z.object({
|
||||
@ -14,30 +16,30 @@ const IntegrationAccountBodySchema = z.object({
|
||||
});
|
||||
|
||||
// Route for creating an integration account directly with an API key
|
||||
const { action, loader } = createActionApiRoute(
|
||||
const { action, loader } = createHybridActionApiRoute(
|
||||
{
|
||||
body: IntegrationAccountBodySchema,
|
||||
allowJWT: true,
|
||||
authorization: {
|
||||
action: "create",
|
||||
subject: "IntegrationAccount",
|
||||
action: "integrationaccount:create",
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
const { integrationDefinitionId, apiKey } = body;
|
||||
const { userId } = authentication;
|
||||
const workspace = await getWorkspaceByUser(authentication.userId);
|
||||
|
||||
try {
|
||||
// Get the integration definition
|
||||
const integrationDefinition = await getIntegrationDefinitionWithId(
|
||||
integrationDefinitionId
|
||||
integrationDefinitionId,
|
||||
);
|
||||
|
||||
if (!integrationDefinition) {
|
||||
return json(
|
||||
{ error: "Integration definition not found" },
|
||||
{ status: 404 }
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,26 +52,22 @@ const { action, loader } = createActionApiRoute(
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
userId
|
||||
userId,
|
||||
workspace?.id,
|
||||
);
|
||||
|
||||
if (!setupResult || !setupResult.accountId) {
|
||||
return json(
|
||||
{ error: "Failed to setup integration with the provided API key" },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create the integration account
|
||||
const integrationAccount = await createIntegrationAccount({
|
||||
accountId: setupResult.accountId,
|
||||
integrationDefinitionId,
|
||||
userId,
|
||||
config: setupResult.config || {},
|
||||
settings: setupResult.settings || {},
|
||||
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||
integrationAccountId: setupResult?.id,
|
||||
});
|
||||
|
||||
return json({ success: true, integrationAccount });
|
||||
return json({ success: true, setupResult });
|
||||
} catch (error) {
|
||||
logger.error("Error creating integration account", {
|
||||
error,
|
||||
@ -78,10 +76,10 @@ const { action, loader } = createActionApiRoute(
|
||||
});
|
||||
return json(
|
||||
{ error: "Failed to create integration account" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
export { action, loader };
|
||||
|
||||
@ -109,6 +109,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const integrationDef =
|
||||
log.activity?.integrationAccount?.integrationDefinition;
|
||||
const logData = log.data as any;
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
source: integrationDef?.name || logData?.source || "Unknown",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { createMCPProxy } from "@core/mcp-proxy";
|
||||
|
||||
import { getIntegrationDefinitionWithSlug } from "~/services/integrationDefinition.server";
|
||||
import { proxyRequest } from "~/utils/proxy.server";
|
||||
import { z } from "zod";
|
||||
import { getIntegrationAccount } from "~/services/integrationAccount.server";
|
||||
|
||||
@ -59,49 +60,35 @@ const { action, loader } = createActionApiRoute(
|
||||
);
|
||||
}
|
||||
|
||||
const { serverUrl, transportStrategy } = spec.mcpAuth;
|
||||
const { serverUrl } = spec.mcpAuth;
|
||||
|
||||
const mcpProxy = createMCPProxy(
|
||||
{
|
||||
serverUrl,
|
||||
timeout: 30000,
|
||||
debug: true,
|
||||
transportStrategy: transportStrategy || "sse-first",
|
||||
// Fix this
|
||||
redirectUrl: "",
|
||||
},
|
||||
// Callback to load credentials from the database
|
||||
async () => {
|
||||
// Find the integration account for this user and integration
|
||||
const integrationAccount = await getIntegrationAccount(
|
||||
integrationDefinition.id,
|
||||
authentication.userId,
|
||||
);
|
||||
|
||||
const integrationConfig =
|
||||
integrationAccount?.integrationConfiguration as any;
|
||||
|
||||
if (!integrationAccount || !integrationConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
tokens: {
|
||||
access_token: integrationConfig.access_token,
|
||||
token_type: integrationConfig.token_type || "bearer",
|
||||
expires_in: integrationConfig.expires_in || 3600,
|
||||
refresh_token: integrationConfig.refresh_token,
|
||||
scope: integrationConfig.scope || "read write",
|
||||
},
|
||||
expiresAt: integrationConfig.expiresAt
|
||||
? new Date(integrationConfig.expiresAt)
|
||||
: new Date(Date.now() + 3600 * 1000),
|
||||
};
|
||||
},
|
||||
// Find the integration account for this user and integration
|
||||
const integrationAccount = await getIntegrationAccount(
|
||||
integrationDefinition.id,
|
||||
authentication.userId,
|
||||
);
|
||||
|
||||
return await mcpProxy(request, "");
|
||||
const integrationConfig =
|
||||
integrationAccount?.integrationConfiguration as any;
|
||||
|
||||
if (!integrationAccount || !integrationConfig || !integrationConfig.mcp) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "No integration account with mcp config",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy the request to the serverUrl
|
||||
return await proxyRequest(
|
||||
request,
|
||||
serverUrl,
|
||||
integrationConfig.mcp.tokens.access_token,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("MCP Proxy Error:", error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
|
||||
@ -6,6 +6,10 @@ import { createMCPAuthClient } from "@core/mcp-proxy";
|
||||
import { logger } from "~/services/logger.service";
|
||||
import { env } from "~/env.server";
|
||||
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
|
||||
import {
|
||||
getIntegrationAccount,
|
||||
getIntegrationAccountForId,
|
||||
} from "~/services/integrationAccount.server";
|
||||
|
||||
const CALLBACK_URL = `${env.APP_ORIGIN}/api/v1/oauth/callback`;
|
||||
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
||||
@ -26,8 +30,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const { integrationDefinitionId, redirectURL } =
|
||||
await getIntegrationDefinitionForState(state);
|
||||
const {
|
||||
integrationDefinitionId,
|
||||
redirectURL,
|
||||
userId,
|
||||
workspaceId,
|
||||
integrationAccountId,
|
||||
} = await getIntegrationDefinitionForState(state);
|
||||
|
||||
try {
|
||||
// For now, we'll assume Linear integration - in the future this should be derived from state
|
||||
@ -35,6 +44,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
integrationDefinitionId,
|
||||
);
|
||||
|
||||
const integrationAccount =
|
||||
await getIntegrationAccountForId(integrationAccountId);
|
||||
|
||||
if (!integrationDefinition) {
|
||||
throw new Error("Integration definition not found");
|
||||
}
|
||||
@ -71,11 +83,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
state,
|
||||
redirect_uri: MCP_CALLBACK_URL,
|
||||
},
|
||||
integrationDefinition,
|
||||
mcp: true,
|
||||
},
|
||||
},
|
||||
// We need to get userId from somewhere - for now using undefined
|
||||
undefined,
|
||||
userId,
|
||||
workspaceId,
|
||||
integrationAccount ?? undefined,
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
|
||||
@ -14,7 +14,7 @@ const { loader } = createActionApiRoute(
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
return await callbackHandler(params, request);
|
||||
return await callbackHandler(params);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
} from "@remix-run/server-runtime";
|
||||
import { sort } from "fast-sort";
|
||||
|
||||
import { useParams, useRevalidator } from "@remix-run/react";
|
||||
import { useParams, useRevalidator, useNavigate } from "@remix-run/react";
|
||||
import {
|
||||
requireUser,
|
||||
requireUserId,
|
||||
@ -25,6 +25,8 @@ import {
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import React from "react";
|
||||
import { ScrollAreaWithAutoScroll } from "~/components/use-auto-scroll";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { json } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
@ -84,6 +86,8 @@ export default function SingleConversation() {
|
||||
const { conversationId } = useParams();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (run) {
|
||||
setConversationResponse(run);
|
||||
@ -129,49 +133,57 @@ export default function SingleConversation() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="!rounded-md">
|
||||
<ResizableHandle className="w-1" />
|
||||
<>
|
||||
<PageHeader
|
||||
title="Conversation"
|
||||
breadcrumbs={[
|
||||
{ label: "Conversations", href: "/home/conversation" },
|
||||
{ label: conversation.title || "Untitled" },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: "New conversation",
|
||||
icon: <Plus size={14} />,
|
||||
onClick: () => navigate("/home/conversation"),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="flex 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_-_56px)] 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}
|
||||
<div className="relative flex h-[calc(100vh_-_56px)] w-full flex-col items-center justify-center overflow-auto">
|
||||
<div className="flex h-[calc(100vh_-_80px)] 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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
CreateConversationSchema,
|
||||
} from "~/services/conversation.server";
|
||||
import { json } from "@remix-run/node";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Only return userId, not the heavy nodeLinks
|
||||
@ -67,6 +68,9 @@ export default function Chat() {
|
||||
const { user } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>{typeof window !== "undefined" && <ConversationNew user={user} />}</>
|
||||
<>
|
||||
<PageHeader title="Conversation" />
|
||||
{typeof window !== "undefined" && <ConversationNew user={user} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { SearchBodyRequest } from "./search";
|
||||
import { SearchService } from "~/services/search.server";
|
||||
import { GraphVisualizationClient } from "~/components/graph/graph-client";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -84,18 +85,21 @@ export default function Dashboard() {
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<div className="home flex h-[calc(100vh_-_56px)] 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">
|
||||
<LoaderCircle size={18} className="mr-1 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading graph...</span>
|
||||
</div>
|
||||
) : (
|
||||
typeof window !== "undefined" &&
|
||||
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
|
||||
)}
|
||||
<>
|
||||
<PageHeader title="Memory graph" />
|
||||
<div className="home flex h-[calc(100vh_-_56px)] 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">
|
||||
<LoaderCircle size={18} className="mr-1 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading graph...</span>
|
||||
</div>
|
||||
) : (
|
||||
typeof window !== "undefined" &&
|
||||
nodeLinks && <GraphVisualizationClient triplets={nodeLinks} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,27 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { useLoaderData, Link } from "@remix-run/react";
|
||||
import {
|
||||
json,
|
||||
type LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
} from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { getIcon, type IconType } from "~/components/icon-utils";
|
||||
import { ArrowLeft, ExternalLink } from "lucide-react";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
|
||||
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
|
||||
import { IngestionRuleSection } from "~/components/integrations/ingestion-rule-section";
|
||||
import { ApiKeyAuthSection } from "~/components/integrations/api-key-auth-section";
|
||||
import { OAuthAuthSection } from "~/components/integrations/oauth-auth-section";
|
||||
import {
|
||||
getIngestionRuleBySource,
|
||||
upsertIngestionRule,
|
||||
} from "~/services/ingestionRule.server";
|
||||
import { Section } from "~/components/integrations/section";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -21,20 +34,79 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
(def) => def.slug === slug || def.id === slug
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
throw new Response("Integration not found", { status: 404 });
|
||||
}
|
||||
|
||||
const activeAccount = integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
);
|
||||
|
||||
let ingestionRule = null;
|
||||
if (activeAccount) {
|
||||
ingestionRule = await getIngestionRuleBySource(
|
||||
activeAccount.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
integration,
|
||||
integrationAccounts,
|
||||
userId,
|
||||
ingestionRule,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const workspace = await requireWorkpace(request);
|
||||
const { slug } = params;
|
||||
|
||||
const formData = await request.formData();
|
||||
const ingestionRuleText = formData.get("ingestionRule") as string;
|
||||
|
||||
if (!ingestionRuleText) {
|
||||
return json({ error: "Ingestion rule is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [integrationDefinitions, integrationAccounts] = await Promise.all([
|
||||
getIntegrationDefinitions(workspace.id),
|
||||
getIntegrationAccounts(userId),
|
||||
]);
|
||||
|
||||
const integration = integrationDefinitions.find(
|
||||
(def) => def.slug === slug || def.id === slug,
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
throw new Response("Integration not found", { status: 404 });
|
||||
}
|
||||
|
||||
const activeAccount = integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
);
|
||||
|
||||
if (!activeAccount) {
|
||||
return json(
|
||||
{ error: "No active integration account found" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await upsertIngestionRule({
|
||||
text: ingestionRuleText,
|
||||
source: activeAccount.id,
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
function parseSpec(spec: any) {
|
||||
if (!spec) return {};
|
||||
if (typeof spec === "string") {
|
||||
@ -48,84 +120,75 @@ function parseSpec(spec: any) {
|
||||
}
|
||||
|
||||
export default function IntegrationDetail() {
|
||||
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
|
||||
const { integration, integrationAccounts, ingestionRule } =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
const activeAccount = useMemo(
|
||||
() =>
|
||||
integrationAccounts.find(
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive
|
||||
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
|
||||
),
|
||||
[integrationAccounts, integration.id]
|
||||
[integrationAccounts, integration.id],
|
||||
);
|
||||
|
||||
const specData = useMemo(() => parseSpec(integration.spec), [integration.spec]);
|
||||
const specData = useMemo(
|
||||
() => parseSpec(integration.spec),
|
||||
[integration.spec],
|
||||
);
|
||||
const hasApiKey = !!specData?.auth?.api_key;
|
||||
const hasOAuth2 = !!specData?.auth?.OAuth2;
|
||||
const hasMCPAuth = !!specData?.mcpAuth;
|
||||
const Component = getIcon(integration.icon as IconType);
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link
|
||||
to="/home/integrations"
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Integrations"
|
||||
breadcrumbs={[
|
||||
{ label: "Integrations", href: "/home/integrations" },
|
||||
{ label: integration?.name || "Untitled" },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: "Request New Integration",
|
||||
icon: <Plus size={14} />,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://github.com/redplanethq/core/issues/new",
|
||||
"_blank",
|
||||
),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="h-[calc(100vh_-_56px)] overflow-hidden p-4 px-5">
|
||||
<Section
|
||||
title={integration.name}
|
||||
description={integration.description}
|
||||
icon={
|
||||
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Integration Details */}
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-background-2 flex h-12 w-12 items-center justify-center rounded">
|
||||
<Component size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-2xl">{integration.name}</CardTitle>
|
||||
<CardDescription className="mt-2 text-base">
|
||||
{integration.description || `Connect to ${integration.name}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Connection Status */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="text-sm font-medium">Status:</span>
|
||||
{activeAccount ? (
|
||||
<span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
|
||||
Not Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Authentication Methods */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication Methods</h3>
|
||||
<div className="space-y-2">
|
||||
{hasApiKey && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ API Key authentication</span>
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked /> API Key authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasOAuth2 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ OAuth 2.0 authentication</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMCPAuth && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">✓ MCP (Model Context Protocol) authentication</span>
|
||||
<span className="inline-flex items-center gap-2 text-sm">
|
||||
<Checkbox checked />
|
||||
OAuth 2.0 authentication
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
|
||||
@ -136,46 +199,47 @@ export default function IntegrationDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connect Button */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<IntegrationAuthDialog integration={integration}>
|
||||
<Button size="lg" className="px-8">
|
||||
Connect to {integration.name}
|
||||
</Button>
|
||||
</IntegrationAuthDialog>
|
||||
{/* Connect Section */}
|
||||
{!activeAccount && (hasApiKey || hasOAuth2) && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Connect to {integration.name}
|
||||
</h3>
|
||||
|
||||
{/* API Key Authentication */}
|
||||
<ApiKeyAuthSection
|
||||
integration={integration}
|
||||
specData={specData}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
|
||||
{/* OAuth Authentication */}
|
||||
<OAuthAuthSection
|
||||
integration={integration}
|
||||
specData={specData}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected Account Info */}
|
||||
{activeAccount && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Connected Account</h3>
|
||||
<div className="rounded-lg border bg-green-50 p-4">
|
||||
<div className="text-sm text-green-800">
|
||||
<p className="font-medium">Account ID: {activeAccount.id}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Connected on {new Date(activeAccount.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConnectedAccountSection activeAccount={activeAccount} />
|
||||
|
||||
{/* Integration Spec Details */}
|
||||
{specData && Object.keys(specData).length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<h3 className="text-lg font-medium">Integration Details</h3>
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<pre className="text-sm text-gray-700">
|
||||
{JSON.stringify(specData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* MCP Authentication Section */}
|
||||
<MCPAuthSection
|
||||
integration={integration}
|
||||
activeAccount={activeAccount as any}
|
||||
hasMCPAuth={hasMCPAuth}
|
||||
/>
|
||||
|
||||
{/* Ingestion Rule Section */}
|
||||
<IngestionRuleSection
|
||||
ingestionRule={ingestionRule}
|
||||
activeAccount={activeAccount}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { requireUserId, requireWorkpace } from "~/services/session.server";
|
||||
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
|
||||
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
|
||||
import { IntegrationGrid } from "~/components/integrations/IntegrationGrid";
|
||||
import { IntegrationGrid } from "~/components/integrations/integration-grid";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@ -38,15 +40,28 @@ export default function Integrations() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
|
||||
<div className="space-y-1 text-base">
|
||||
<p className="text-muted-foreground">Connect your tools and services</p>
|
||||
</div>
|
||||
|
||||
<IntegrationGrid
|
||||
integrations={integrationDefinitions}
|
||||
activeAccountIds={activeAccountIds}
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Integrations"
|
||||
actions={[
|
||||
{
|
||||
label: "Request New Integration",
|
||||
icon: <Plus size={14} />,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
"https://github.com/redplanethq/core/issues/new",
|
||||
"_blank",
|
||||
),
|
||||
variant: "secondary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="home flex h-[calc(100vh_-_56px)] flex-col overflow-y-auto p-4 px-5">
|
||||
<IntegrationGrid
|
||||
integrations={integrationDefinitions}
|
||||
activeAccountIds={activeAccountIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,35 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useLogs } from "~/hooks/use-logs";
|
||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||
import { AppContainer, PageContainer, PageBody } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Activity } from "lucide-react";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
|
||||
export default function LogsActivity() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
|
||||
const {
|
||||
logs,
|
||||
hasMore,
|
||||
loadMore,
|
||||
availableSources,
|
||||
isLoading,
|
||||
isInitialLoad
|
||||
} = useLogs({
|
||||
endpoint: '/api/v1/logs/activity',
|
||||
source: selectedSource,
|
||||
status: selectedStatus
|
||||
|
||||
const {
|
||||
logs,
|
||||
hasMore,
|
||||
loadMore,
|
||||
availableSources,
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
} = useLogs({
|
||||
endpoint: "/api/v1/logs/activity",
|
||||
source: selectedSource,
|
||||
status: selectedStatus,
|
||||
});
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</AppContainer>
|
||||
@ -37,80 +39,64 @@ export default function LogsActivity() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<PageContainer>
|
||||
<PageBody>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Activity Ingestion Logs</h1>
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
tabs={[
|
||||
{
|
||||
label: "All",
|
||||
value: "all",
|
||||
isActive: false,
|
||||
onClick: () => navigate("/home/logs/all"),
|
||||
},
|
||||
{
|
||||
label: "Activity",
|
||||
value: "activity",
|
||||
isActive: true,
|
||||
onClick: () => navigate("/home/logs/activity"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="flex h-[calc(100vh_-_56px)] flex-col space-y-6 p-4 px-5">
|
||||
{logs.length > 0 && (
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="space-y-4">
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="bg-background-2 flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Activity className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
No activity logs found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
View ingestion logs for activities from connected integrations
|
||||
{selectedSource || selectedStatus
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No activity ingestion logs are available yet."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{logs.length} activity logs loaded
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Activity Ingestion Queue</h2>
|
||||
{hasMore && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Scroll to load more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No activity logs found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedSource || selectedStatus
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'No activity ingestion logs are available yet.'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
</AppContainer>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useLogs } from "~/hooks/use-logs";
|
||||
import { LogsFilters } from "~/components/logs/logs-filters";
|
||||
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
|
||||
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Database } from "lucide-react";
|
||||
import { PageHeader } from "~/components/common/page-header";
|
||||
|
||||
export default function LogsAll() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedSource, setSelectedSource] = useState<string | undefined>();
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
|
||||
@ -36,42 +39,63 @@ export default function LogsAll() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 px-5">
|
||||
{/* Filters */}
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
<>
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
tabs={[
|
||||
{
|
||||
label: "All",
|
||||
value: "all",
|
||||
isActive: true,
|
||||
onClick: () => navigate("/home/logs/all"),
|
||||
},
|
||||
{
|
||||
label: "Activity",
|
||||
value: "activity",
|
||||
isActive: false,
|
||||
onClick: () => navigate("/home/logs/activity"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="space-y-4">
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="bg-background-2 flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No logs found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedSource || selectedStatus
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No ingestion logs are available yet."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
<div className="space-y-6 p-4 px-5">
|
||||
{/* Filters */}
|
||||
{logs.length > 0 && (
|
||||
<LogsFilters
|
||||
availableSources={availableSources}
|
||||
selectedSource={selectedSource}
|
||||
selectedStatus={selectedStatus}
|
||||
onSourceChange={setSelectedSource}
|
||||
onStatusChange={setSelectedStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logs List */}
|
||||
<div className="space-y-4">
|
||||
{logs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="bg-background-2 flex items-center justify-center py-16">
|
||||
<div className="text-center">
|
||||
<Database className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No logs found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedSource || selectedStatus
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No ingestion logs are available yet."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<VirtualLogsList
|
||||
logs={logs}
|
||||
hasMore={hasMore}
|
||||
loadMore={loadMore}
|
||||
isLoading={isLoading}
|
||||
height={600}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ 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";
|
||||
import { FloatingIngestionStatus } from "~/components/ingestion/floating-ingestion-status";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@ -40,8 +39,7 @@ export default function Home() {
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset className="bg-background-2 h-full rounded pr-0">
|
||||
<SiteHeader />
|
||||
<div className="flex h-[calc(100vh_-_56px)] flex-col rounded">
|
||||
<div className="flex h-full flex-col rounded">
|
||||
<div className="@container/main flex h-full flex-col gap-2">
|
||||
<div className="flex h-full flex-col">
|
||||
<Outlet />
|
||||
|
||||
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
56
apps/webapp/app/services/ingestionRule.server.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function getIngestionRuleBySource(
|
||||
source: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
return await prisma.ingestionRule.findFirst({
|
||||
where: {
|
||||
source,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Need to fix this later
|
||||
export async function upsertIngestionRule({
|
||||
text,
|
||||
source,
|
||||
workspaceId,
|
||||
userId,
|
||||
}: {
|
||||
text: string;
|
||||
source: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
// Find existing rule first
|
||||
const existingRule = await prisma.ingestionRule.findFirst({
|
||||
where: {
|
||||
source,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRule) {
|
||||
// Update existing rule
|
||||
return await prisma.ingestionRule.update({
|
||||
where: {
|
||||
id: existingRule.id,
|
||||
},
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new rule
|
||||
return await prisma.ingestionRule.create({
|
||||
data: {
|
||||
text,
|
||||
source,
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,12 @@
|
||||
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,
|
||||
) {
|
||||
logger.info(`Loading integration ${integrationDefinition.slug}`);
|
||||
|
||||
let pat = "";
|
||||
let patId = "";
|
||||
if (userId) {
|
||||
// Use the integration slug as the token name for uniqueness
|
||||
const tokenResult = await getOrCreatePersonalAccessToken({
|
||||
name: integrationDefinition.slug ?? "integration",
|
||||
userId,
|
||||
});
|
||||
pat = tokenResult.token ?? "";
|
||||
patId = tokenResult.id ?? "";
|
||||
}
|
||||
|
||||
return {
|
||||
integrationDefinition,
|
||||
pat,
|
||||
patId,
|
||||
};
|
||||
}
|
||||
import type {
|
||||
IntegrationAccount,
|
||||
IntegrationDefinitionV2,
|
||||
} from "@core/database";
|
||||
|
||||
/**
|
||||
* Triggers an integration run asynchronously.
|
||||
@ -43,11 +16,24 @@ export async function runIntegrationTriggerAsync(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: any,
|
||||
userId?: string,
|
||||
workspaceId?: string,
|
||||
) {
|
||||
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
|
||||
logger.info(
|
||||
`Triggering async integration run for ${integrationDefinition.slug}`,
|
||||
{
|
||||
integrationId: integrationDefinition.id,
|
||||
event: event.event,
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
||||
...params,
|
||||
event,
|
||||
integrationDefinition,
|
||||
event: event.event,
|
||||
eventBody: event.eventBody,
|
||||
integrationAccount: event.integrationAccount,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@ -59,14 +45,26 @@ export async function runIntegrationTrigger(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: any,
|
||||
userId?: string,
|
||||
workspaceId?: string,
|
||||
integrationAccount?: IntegrationAccount,
|
||||
) {
|
||||
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
|
||||
logger.info(
|
||||
`Triggering sync integration run for ${integrationDefinition.slug}`,
|
||||
{
|
||||
integrationId: integrationDefinition.id,
|
||||
event: event.event,
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const response = await tasks.triggerAndPoll<typeof integrationRun>(
|
||||
"integration-run",
|
||||
{
|
||||
...params,
|
||||
integrationAccount: event.integrationAccount,
|
||||
integrationDefinition,
|
||||
integrationAccount,
|
||||
workspaceId,
|
||||
userId,
|
||||
event: event.event,
|
||||
eventBody: event.eventBody,
|
||||
},
|
||||
|
||||
@ -13,27 +13,10 @@ export const getIntegrationAccount = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationAccount = async ({
|
||||
integrationDefinitionId,
|
||||
userId,
|
||||
accountId,
|
||||
config,
|
||||
settings,
|
||||
}: {
|
||||
integrationDefinitionId: string;
|
||||
userId: string;
|
||||
accountId: string;
|
||||
config?: Record<string, any>;
|
||||
settings?: Record<string, any>;
|
||||
}) => {
|
||||
return prisma.integrationAccount.create({
|
||||
data: {
|
||||
accountId,
|
||||
integrationDefinitionId,
|
||||
integratedById: userId,
|
||||
config: config || {},
|
||||
settings: settings || {},
|
||||
isActive: true,
|
||||
export const getIntegrationAccountForId = async (id: string) => {
|
||||
return await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -49,3 +32,13 @@ export const getIntegrationAccounts = async (userId: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getIntegrationAccountForSlug = async (slug: string) => {
|
||||
return await prisma.integrationAccount.findFirst({
|
||||
where: {
|
||||
integrationDefinition: {
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { type OAuth2Params } from "@core/types";
|
||||
import { IsBoolean, IsString } from "class-validator";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
import type { IntegrationDefinitionV2 } from "@core/database";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -25,12 +25,17 @@ export class OAuthBodyInterface {
|
||||
|
||||
@IsString()
|
||||
integrationDefinitionId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
integrationAccountId?: string;
|
||||
}
|
||||
|
||||
export const OAuthBodySchema = z.object({
|
||||
redirectURL: z.string(),
|
||||
integrationDefinitionId: z.string(),
|
||||
mcp: z.boolean().optional().default(false),
|
||||
integrationAccountId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CallbackParams = Record<string, string>;
|
||||
|
||||
@ -24,17 +24,20 @@ const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
|
||||
const session: Record<string, SessionRecord> = {};
|
||||
const mcpSession: Record<
|
||||
string,
|
||||
{ integrationDefinitionId: string; redirectURL: string }
|
||||
{
|
||||
integrationDefinitionId: string;
|
||||
redirectURL: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
integrationAccountId: string;
|
||||
}
|
||||
> = {};
|
||||
|
||||
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,
|
||||
) {
|
||||
export async function callbackHandler(params: CallbackParams) {
|
||||
if (!params.state) {
|
||||
throw new Error("No state found");
|
||||
}
|
||||
@ -134,14 +137,14 @@ export async function callbackHandler(
|
||||
...params,
|
||||
redirect_uri: CALLBACK_URL,
|
||||
},
|
||||
integrationDefinition,
|
||||
},
|
||||
},
|
||||
sessionRecord.userId,
|
||||
sessionRecord.workspaceId,
|
||||
);
|
||||
|
||||
await tasks.trigger<typeof scheduler>("scheduler", {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
integrationAccountId: integrationAccount?.id,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
@ -253,7 +256,7 @@ export async function getRedirectURLForMCP(
|
||||
userId: string,
|
||||
workspaceId?: string,
|
||||
) {
|
||||
const { integrationDefinitionId } = oAuthBody;
|
||||
const { integrationDefinitionId, integrationAccountId } = oAuthBody;
|
||||
|
||||
logger.info(
|
||||
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
|
||||
@ -265,6 +268,10 @@ export async function getRedirectURLForMCP(
|
||||
integrationDefinitionId,
|
||||
);
|
||||
|
||||
if (!integrationAccountId) {
|
||||
throw new Error("No integration account found");
|
||||
}
|
||||
|
||||
if (!integrationDefinition) {
|
||||
throw new Error("No integration definition found");
|
||||
}
|
||||
@ -290,6 +297,9 @@ export async function getRedirectURLForMCP(
|
||||
mcpSession[state] = {
|
||||
integrationDefinitionId: integrationDefinition.id,
|
||||
redirectURL,
|
||||
userId,
|
||||
workspaceId: workspaceId as string,
|
||||
integrationAccountId,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -42,7 +42,7 @@ export class WebhookService {
|
||||
|
||||
if (integrationDefinition) {
|
||||
try {
|
||||
const accountIdResponse = await runIntegrationTrigger(
|
||||
const identifyResponse = await runIntegrationTrigger(
|
||||
integrationDefinition,
|
||||
{
|
||||
event: IntegrationEventType.IDENTIFY,
|
||||
@ -55,12 +55,39 @@ export class WebhookService {
|
||||
|
||||
let accountId: string | undefined;
|
||||
|
||||
if (
|
||||
accountIdResponse?.message?.startsWith("The event payload type is")
|
||||
) {
|
||||
accountId = undefined;
|
||||
// Handle new CLI message format response
|
||||
if (identifyResponse?.success && identifyResponse?.result) {
|
||||
// Check if there are identifiers in the response
|
||||
if (
|
||||
identifyResponse.result.identifiers &&
|
||||
identifyResponse.result.identifiers.length > 0
|
||||
) {
|
||||
accountId = identifyResponse.result.identifiers[0].id;
|
||||
} else if (
|
||||
identifyResponse.result.activities &&
|
||||
identifyResponse.result.activities.length > 0
|
||||
) {
|
||||
// Sometimes the account ID might be in activities data
|
||||
const firstActivity = identifyResponse.result.activities[0];
|
||||
accountId = firstActivity.accountId || firstActivity.id;
|
||||
} else {
|
||||
// Check raw output for backward compatibility
|
||||
accountId = identifyResponse.rawOutput?.trim();
|
||||
}
|
||||
} else if (identifyResponse?.error) {
|
||||
logger.warn("Integration IDENTIFY command failed", {
|
||||
error: identifyResponse.error,
|
||||
sourceName,
|
||||
});
|
||||
} else {
|
||||
accountId = accountIdResponse;
|
||||
// Handle legacy response format for backward compatibility
|
||||
if (
|
||||
identifyResponse?.message?.startsWith("The event payload type is")
|
||||
) {
|
||||
accountId = undefined;
|
||||
} else {
|
||||
accountId = identifyResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
@ -68,6 +95,17 @@ export class WebhookService {
|
||||
where: { accountId },
|
||||
include: { integrationDefinition: true },
|
||||
});
|
||||
|
||||
logger.info("Found integration account for webhook", {
|
||||
accountId,
|
||||
integrationAccountId: integrationAccount?.id,
|
||||
sourceName,
|
||||
});
|
||||
} else {
|
||||
logger.warn("No account ID found from IDENTIFY command", {
|
||||
sourceName,
|
||||
response: identifyResponse,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to identify integration account", {
|
||||
@ -85,22 +123,38 @@ export class WebhookService {
|
||||
|
||||
if (integrationAccount) {
|
||||
try {
|
||||
await runIntegrationTrigger(
|
||||
logger.info(`Processing webhook for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
integrationSlug: integrationAccount.integrationDefinition.slug,
|
||||
});
|
||||
|
||||
const processResponse = await runIntegrationTrigger(
|
||||
integrationAccount.integrationDefinition,
|
||||
{
|
||||
event: IntegrationEventType.PROCESS,
|
||||
integrationAccount,
|
||||
eventBody: {
|
||||
eventHeaders,
|
||||
eventData: { ...eventBody },
|
||||
},
|
||||
},
|
||||
integrationAccount.integratedById,
|
||||
integrationAccount.workspaceId,
|
||||
integrationAccount,
|
||||
);
|
||||
|
||||
logger.log(`Successfully processed webhook for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
if (processResponse?.success) {
|
||||
logger.log(`Successfully processed webhook for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
activitiesCreated: processResponse.result?.activities?.length || 0,
|
||||
messagesProcessed: processResponse.messages?.length || 0,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`Webhook processing had issues for ${sourceName}`, {
|
||||
integrationAccountId: integrationAccount.id,
|
||||
error: processResponse?.error,
|
||||
success: processResponse?.success,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process webhook for ${sourceName}`, {
|
||||
error,
|
||||
|
||||
@ -107,10 +107,31 @@ const websearchTool = tool({
|
||||
parameters: WebSearchSchema,
|
||||
});
|
||||
|
||||
const loadMCPTools = tool({
|
||||
description:
|
||||
"Load tools for a specific integration. Call this when you need to use a third-party service.",
|
||||
parameters: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
integration: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
description:
|
||||
'Array of integration names to load (e.g., ["github", "linear", "slack"])',
|
||||
},
|
||||
},
|
||||
required: ["integration"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const internalTools = [
|
||||
"core--progress_update",
|
||||
"core--search_memory",
|
||||
"core--add_memory",
|
||||
"core--load_mcp",
|
||||
];
|
||||
|
||||
async function addResources(messages: CoreMessage[], resources: Resource[]) {
|
||||
@ -198,6 +219,7 @@ async function makeNextCall(
|
||||
TOOLS: ToolSet,
|
||||
totalCost: TotalCost,
|
||||
guardLoop: number,
|
||||
mcpServers: string[],
|
||||
): Promise<LLMOutputInterface> {
|
||||
const { context, history, previousHistory } = executionState;
|
||||
|
||||
@ -205,6 +227,7 @@ async function makeNextCall(
|
||||
USER_MESSAGE: executionState.query,
|
||||
CONTEXT: context,
|
||||
USER_MEMORY: executionState.userMemoryContext,
|
||||
AVAILABLE_MCP_TOOLS: mcpServers.join(", "),
|
||||
};
|
||||
|
||||
let messages: CoreMessage[] = [];
|
||||
@ -257,15 +280,19 @@ export async function* run(
|
||||
previousHistory: CoreMessage[],
|
||||
mcp: MCP,
|
||||
stepHistory: HistoryStep[],
|
||||
mcpServers: string[],
|
||||
mcpHeaders: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): AsyncGenerator<AgentMessage, any, any> {
|
||||
let guardLoop = 0;
|
||||
|
||||
let tools = {
|
||||
...(await mcp.allTools()),
|
||||
"core--progress_update": progressUpdateTool,
|
||||
"core--search_memory": searchMemoryTool,
|
||||
"core--add_memory": addMemoryTool,
|
||||
"core--websearch": websearchTool,
|
||||
"core--load_mcp": loadMCPTools,
|
||||
};
|
||||
|
||||
logger.info("Tools have been formed");
|
||||
@ -301,6 +328,7 @@ export async function* run(
|
||||
tools,
|
||||
totalCost,
|
||||
guardLoop,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
let toolCallInfo;
|
||||
@ -532,6 +560,14 @@ export async function* run(
|
||||
result =
|
||||
"Web search failed - please check your search configuration";
|
||||
}
|
||||
} else if (toolName === "load_mcp") {
|
||||
// Load MCP integration and update available tools
|
||||
await mcp.load(skillInput.integration, mcpHeaders);
|
||||
tools = {
|
||||
...tools,
|
||||
...(await mcp.allTools()),
|
||||
};
|
||||
result = "MCP integration loaded successfully";
|
||||
}
|
||||
}
|
||||
// Handle other MCP tools
|
||||
|
||||
@ -37,9 +37,12 @@ export const chat = task({
|
||||
|
||||
const { previousHistory, ...otherData } = payload.context;
|
||||
|
||||
const { agents = [] } = payload.context;
|
||||
// Initialise mcp
|
||||
const mcpHeaders = { Authorization: `Bearer ${init?.token}` };
|
||||
const mcp = new MCP();
|
||||
await mcp.init();
|
||||
await mcp.load(agents, mcpHeaders);
|
||||
|
||||
// Prepare context with additional metadata
|
||||
const context = {
|
||||
@ -75,6 +78,8 @@ export const chat = task({
|
||||
previousExecutionHistory,
|
||||
mcp,
|
||||
stepHistory,
|
||||
init?.mcpServers ?? [],
|
||||
mcpHeaders,
|
||||
);
|
||||
|
||||
const stream = await metadata.stream("messages", llmResponse);
|
||||
|
||||
@ -98,6 +98,15 @@ MEMORY USAGE:
|
||||
If memory access is unavailable, proceed to web search or rely on current conversation
|
||||
</memory>
|
||||
|
||||
<external_services>
|
||||
- Available integrations: {{AVAILABLE_MCP_TOOLS}}
|
||||
- To use: load_mcp with EXACT integration name from the available list
|
||||
- Can load multiple at once with an array
|
||||
- Only load when tools are NOT already available in your current toolset
|
||||
- If a tool is already available, use it directly without load_mcp
|
||||
- If requested integration unavailable: inform user politely
|
||||
</external_services>
|
||||
|
||||
<tool_calling>
|
||||
You have tools at your disposal to assist users:
|
||||
|
||||
|
||||
@ -3,8 +3,6 @@ import { IntegrationEventType } from "@core/types";
|
||||
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import { type integrationRun } from "./integration-run";
|
||||
import { getOrCreatePersonalAccessToken } from "../utils/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -27,7 +25,7 @@ export const integrationRunSchedule = schedules.task({
|
||||
|
||||
if (!integrationAccount) {
|
||||
const deletedSchedule = await schedules.del(externalId);
|
||||
logger.info("No integration account found");
|
||||
logger.info("No integration account found, deleting schedule");
|
||||
return deletedSchedule;
|
||||
}
|
||||
|
||||
@ -36,22 +34,20 @@ export const integrationRunSchedule = schedules.task({
|
||||
return null;
|
||||
}
|
||||
|
||||
const pat = await getOrCreatePersonalAccessToken({
|
||||
name: `integration_scheduled_${nanoid(10)}`,
|
||||
userId: integrationAccount.workspace.userId as string,
|
||||
logger.info("Triggering scheduled integration run", {
|
||||
integrationId: integrationAccount.integrationDefinition.id,
|
||||
integrationSlug: integrationAccount.integrationDefinition.slug,
|
||||
accountId: integrationAccount.id,
|
||||
});
|
||||
|
||||
if (!pat || !pat.token) {
|
||||
logger.info("No pat token found");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await tasks.trigger<typeof integrationRun>("integration-run", {
|
||||
event: IntegrationEventType.SYNC,
|
||||
pat: pat.token,
|
||||
patId: pat.id,
|
||||
integrationAccount,
|
||||
integrationDefinition: integrationAccount.integrationDefinition,
|
||||
eventBody: {
|
||||
scheduled: true,
|
||||
scheduledAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,100 +1,435 @@
|
||||
import createLoadRemoteModule, {
|
||||
createRequires,
|
||||
} from "@paciolan/remote-module-loader";
|
||||
|
||||
import { logger, task } from "@trigger.dev/sdk/v3";
|
||||
import axios from "axios";
|
||||
import { spawn } from "child_process";
|
||||
import {
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
mkdtempSync,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
} from "fs";
|
||||
import { join, isAbsolute, resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
type IntegrationDefinitionV2,
|
||||
type IntegrationAccount,
|
||||
} from "@core/database";
|
||||
import { deletePersonalAccessToken } from "../utils/utils";
|
||||
import { type IntegrationEventType } from "@core/types";
|
||||
import { IntegrationEventType, type Message } from "@core/types";
|
||||
import { extractMessagesFromOutput } from "../utils/cli-message-handler";
|
||||
import {
|
||||
createActivities,
|
||||
createIntegrationAccount,
|
||||
saveIntegrationAccountState,
|
||||
saveMCPConfig,
|
||||
} from "../utils/message-utils";
|
||||
|
||||
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;
|
||||
/**
|
||||
* Determines if a string is a URL.
|
||||
*/
|
||||
function isUrl(str: string): boolean {
|
||||
try {
|
||||
// Accepts http, https, file, etc.
|
||||
const url = new URL(str);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getRequires = (axios: any) => createRequires({ axios });
|
||||
/**
|
||||
* Loads integration file from a URL or a local path.
|
||||
*/
|
||||
const loadIntegrationSource = async (source: string): Promise<string> => {
|
||||
if (!source) {
|
||||
throw new Error("Integration source is not provided");
|
||||
}
|
||||
|
||||
// If it's a URL, fetch it
|
||||
if (isUrl(source)) {
|
||||
try {
|
||||
const response = await axios.get(source);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to fetch integration file from ${source}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, treat as a local file path (absolute or relative)
|
||||
let filePath = source;
|
||||
if (!isAbsolute(filePath)) {
|
||||
filePath = resolve(process.cwd(), filePath);
|
||||
}
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
return readFileSync(filePath, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read integration file from path ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Integration source is not found: ${source}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes integration CLI command with integration file
|
||||
*/
|
||||
const executeCLICommand = async (
|
||||
integrationFile: string,
|
||||
eventType: IntegrationEventType,
|
||||
eventBody?: any,
|
||||
config?: any,
|
||||
integrationDefinition?: IntegrationDefinitionV2,
|
||||
state?: any,
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create temporary directory for the integration file
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "integration-"));
|
||||
const integrationPath = join(tempDir, "integration.js");
|
||||
|
||||
try {
|
||||
// Write integration file to temporary location
|
||||
writeFileSync(integrationPath, integrationFile);
|
||||
|
||||
// Build command arguments based on event type and integration-cli spec
|
||||
const args = [integrationPath];
|
||||
|
||||
switch (eventType) {
|
||||
case IntegrationEventType.SETUP:
|
||||
args.push("setup");
|
||||
args.push("--event-body", JSON.stringify(eventBody || {}));
|
||||
args.push(
|
||||
"--integration-definition",
|
||||
JSON.stringify(integrationDefinition || {}),
|
||||
);
|
||||
break;
|
||||
|
||||
case IntegrationEventType.IDENTIFY:
|
||||
args.push("identify");
|
||||
args.push("--webhook-data", JSON.stringify(eventBody || {}));
|
||||
break;
|
||||
|
||||
case IntegrationEventType.PROCESS:
|
||||
args.push("process");
|
||||
args.push(
|
||||
"--event-data",
|
||||
JSON.stringify(eventBody?.eventData || eventBody || {}),
|
||||
);
|
||||
args.push("--config", JSON.stringify(config || {}));
|
||||
break;
|
||||
|
||||
case IntegrationEventType.SYNC:
|
||||
args.push("sync");
|
||||
args.push("--config", JSON.stringify(config || {}));
|
||||
args.push("--state", JSON.stringify(state || {}));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported event type: ${eventType}`);
|
||||
}
|
||||
|
||||
// Use node to execute the integration file
|
||||
const childProcess = spawn("node", args, {
|
||||
env: process.env,
|
||||
cwd: tempDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
childProcess.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on("close", (code) => {
|
||||
try {
|
||||
// Clean up temporary file
|
||||
unlinkSync(integrationPath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn("Failed to cleanup temporary file", {
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Integration CLI failed with exit code ${code}: ${stderr}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
try {
|
||||
unlinkSync(integrationPath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn("Failed to cleanup temporary file", {
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
unlinkSync(integrationPath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn("Failed to cleanup temporary file", {
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function handleActivityMessage(
|
||||
messages: Message[],
|
||||
integrationAccountId: string,
|
||||
userId: string,
|
||||
): Promise<any> {
|
||||
return createActivities({ integrationAccountId, messages, userId });
|
||||
}
|
||||
|
||||
async function handleStateMessage(
|
||||
messages: Message[],
|
||||
integrationAccountId: string,
|
||||
): Promise<any> {
|
||||
// TODO: Implement state message handling
|
||||
return saveIntegrationAccountState({ messages, integrationAccountId });
|
||||
}
|
||||
|
||||
async function handleIdentifierMessage(message: Message): Promise<any> {
|
||||
return message.data;
|
||||
}
|
||||
|
||||
async function handleAccountMessage(
|
||||
messages: Message[],
|
||||
integrationDefinition: IntegrationDefinitionV2,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
integrationAccountId: string,
|
||||
): Promise<any> {
|
||||
const message = messages[0];
|
||||
const mcp = message.data.mcp;
|
||||
|
||||
if (mcp) {
|
||||
return await saveMCPConfig({
|
||||
integrationAccountId,
|
||||
config: message.data.config,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle only one messages since account gets created only for one
|
||||
const {
|
||||
data: { settings, config, accountId },
|
||||
} = messages[0];
|
||||
return await createIntegrationAccount({
|
||||
integrationDefinitionId: integrationDefinition.id,
|
||||
workspaceId,
|
||||
settings,
|
||||
config,
|
||||
accountId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles CLI messages array and performs necessary actions based on message types
|
||||
*/
|
||||
async function handleMessageResponse(
|
||||
messages: Message[],
|
||||
integrationDefinition: IntegrationDefinitionV2,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
integrationAccountId?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info("Handling CLI message response", {
|
||||
integrationId: integrationDefinition.id,
|
||||
messageCount: messages.length,
|
||||
messageTypes: messages.map((m) => m.type),
|
||||
});
|
||||
|
||||
// Group messages by type
|
||||
const grouped: Record<string, Message[]> = {};
|
||||
for (const message of messages) {
|
||||
if (!grouped[message.type]) {
|
||||
grouped[message.type] = [];
|
||||
}
|
||||
grouped[message.type].push(message);
|
||||
}
|
||||
|
||||
// Handle "activity" messages
|
||||
if (grouped["activity"]) {
|
||||
return await handleActivityMessage(
|
||||
grouped["activity"],
|
||||
integrationAccountId as string,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle "state" messages
|
||||
if (grouped["state"]) {
|
||||
return await handleStateMessage(
|
||||
grouped["state"],
|
||||
integrationAccountId as string,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle "identifier" messages
|
||||
if (grouped["identifier"]) {
|
||||
return await handleIdentifierMessage(grouped["identifier"][0]);
|
||||
}
|
||||
|
||||
// Handle "account" messages (these may involve Prisma writes)
|
||||
if (grouped["account"]) {
|
||||
return await handleAccountMessage(
|
||||
grouped["account"],
|
||||
integrationDefinition,
|
||||
workspaceId,
|
||||
userId,
|
||||
integrationAccountId as string,
|
||||
);
|
||||
}
|
||||
|
||||
// Warn for unknown message types
|
||||
for (const type of Object.keys(grouped)) {
|
||||
if (!["activity", "state", "identifier", "account"].includes(type)) {
|
||||
for (const message of grouped[type]) {
|
||||
logger.warn("Unknown message type", {
|
||||
messageType: type,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle CLI message response", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
integrationId: integrationDefinition.id,
|
||||
messages,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old event-based handlers as they are replaced by message-type handlers above
|
||||
|
||||
export const integrationRun = task({
|
||||
id: "integration-run",
|
||||
run: async ({
|
||||
pat,
|
||||
patId,
|
||||
eventBody,
|
||||
integrationAccount,
|
||||
integrationDefinition,
|
||||
event,
|
||||
workspaceId,
|
||||
userId,
|
||||
}: {
|
||||
pat: string;
|
||||
patId: string;
|
||||
// This is the event you want to pass to the integration
|
||||
event: IntegrationEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
eventBody?: any;
|
||||
integrationDefinition: IntegrationDefinitionV2;
|
||||
integrationAccount?: IntegrationAccount;
|
||||
workspaceId?: string;
|
||||
userId?: string;
|
||||
}) => {
|
||||
const remoteModuleLoad = await loadRemoteModule(
|
||||
getRequires(createAxiosInstance(pat)),
|
||||
);
|
||||
try {
|
||||
logger.info(
|
||||
`Starting integration run for ${integrationDefinition.slug}`,
|
||||
{
|
||||
event,
|
||||
integrationId: integrationDefinition.id,
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
||||
);
|
||||
// Load the integration file from a URL or a local path
|
||||
const integrationSource = integrationDefinition.url as string;
|
||||
const integrationFile = await loadIntegrationSource(integrationSource);
|
||||
logger.info(`Loaded integration file from ${integrationSource}`);
|
||||
|
||||
const integrationFunction = await remoteModuleLoad(
|
||||
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
|
||||
);
|
||||
// Prepare enhanced event body based on event type
|
||||
let enhancedEventBody = eventBody;
|
||||
|
||||
// const integrationFunction = await remoteModuleLoad(
|
||||
// `${integrationDefinition.url}`,
|
||||
// );
|
||||
// For SETUP events, include OAuth response and parameters
|
||||
if (event === IntegrationEventType.SETUP) {
|
||||
enhancedEventBody = {
|
||||
...eventBody,
|
||||
};
|
||||
}
|
||||
|
||||
// Construct the proper IntegrationEventPayload structure
|
||||
const integrationEventPayload = {
|
||||
event,
|
||||
eventBody: { ...eventBody, integrationDefinition },
|
||||
config: integrationAccount?.integrationConfiguration || {},
|
||||
};
|
||||
// For PROCESS events, ensure eventData is properly structured
|
||||
if (event === IntegrationEventType.PROCESS) {
|
||||
enhancedEventBody = {
|
||||
eventData: eventBody,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await integrationFunction.run(integrationEventPayload);
|
||||
logger.info(`Executing integration CLI`, {
|
||||
event,
|
||||
integrationId: integrationDefinition.id,
|
||||
hasConfig: !!integrationAccount?.integrationConfiguration,
|
||||
});
|
||||
|
||||
await deletePersonalAccessToken(patId);
|
||||
const settings = integrationAccount?.settings as any;
|
||||
|
||||
logger.info("Personal access token deleted");
|
||||
// Execute the CLI command using node
|
||||
const output = await executeCLICommand(
|
||||
integrationFile,
|
||||
event,
|
||||
enhancedEventBody,
|
||||
integrationAccount?.integrationConfiguration,
|
||||
integrationDefinition,
|
||||
settings?.state,
|
||||
);
|
||||
|
||||
return result;
|
||||
logger.info("Integration CLI executed successfully");
|
||||
|
||||
// Process the output messages
|
||||
const messages = extractMessagesFromOutput(output);
|
||||
|
||||
logger.info("Integration run completed", {
|
||||
messageCount: messages.length,
|
||||
messageTypes: messages.map((m) => m.type),
|
||||
});
|
||||
|
||||
// Handle all CLI messages through the generic handler
|
||||
return await handleMessageResponse(
|
||||
messages,
|
||||
integrationDefinition,
|
||||
workspaceId as string,
|
||||
userId as string,
|
||||
integrationAccount?.id,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = `Integration run failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
logger.error(errorMessage, {
|
||||
integrationId: integrationDefinition.id,
|
||||
event,
|
||||
error,
|
||||
});
|
||||
|
||||
// For SETUP commands, we need to throw the error so OAuth callback can handle it
|
||||
if (event === IntegrationEventType.SETUP) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For other commands, return error in appropriate format
|
||||
return {
|
||||
error: errorMessage,
|
||||
errors: [errorMessage],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
45
apps/webapp/app/trigger/utils/cli-message-handler.ts
Normal file
45
apps/webapp/app/trigger/utils/cli-message-handler.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { type Message } from "@core/types";
|
||||
|
||||
/**
|
||||
* Validates if a message has the correct structure
|
||||
* @param message - Message to validate
|
||||
* @returns True if valid, false otherwise
|
||||
*/
|
||||
export function isValidMessage(message: any): message is Message {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
typeof message.type === "string" &&
|
||||
message.data !== undefined &&
|
||||
["spec", "activity", "state", "identifier", "account"].includes(
|
||||
message.type,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and validates messages from CLI output
|
||||
* @param output - Raw CLI output string
|
||||
* @returns Array of valid messages
|
||||
*/
|
||||
export function extractMessagesFromOutput(output: string): Message[] {
|
||||
const messages: Message[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (isValidMessage(parsed)) {
|
||||
messages.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
// Line is not JSON, skip it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@ -3,17 +3,15 @@ import { logger } from "@trigger.dev/sdk/v3";
|
||||
import { jsonSchema, tool, type ToolSet } from "ai";
|
||||
|
||||
import { type MCPTool } from "./types";
|
||||
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
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() {
|
||||
@ -23,24 +21,18 @@ export class MCP {
|
||||
return Client;
|
||||
}
|
||||
|
||||
async load(agents: string[], mcpConfig: any) {
|
||||
async load(agents: string[], headers: 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 ?? "",
|
||||
});
|
||||
return await this.connectToServer(
|
||||
agent,
|
||||
`${process.env.API_BASE_URL}/api/v1/mcp/${agent}`,
|
||||
headers,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private static async importStdioTransport() {
|
||||
const { StdioClientTransport } = await import("./stdio");
|
||||
return StdioClientTransport;
|
||||
}
|
||||
|
||||
async allTools(): Promise<ToolSet> {
|
||||
const clientEntries = Object.entries(this.clients);
|
||||
|
||||
@ -113,12 +105,7 @@ export class MCP {
|
||||
return response;
|
||||
}
|
||||
|
||||
async connectToServer(
|
||||
name: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
env: any,
|
||||
) {
|
||||
async connectToServer(name: string, url: string, headers: any) {
|
||||
try {
|
||||
const client = new this.Client(
|
||||
{
|
||||
@ -130,12 +117,9 @@ export class MCP {
|
||||
},
|
||||
);
|
||||
|
||||
// Conf
|
||||
// igure the transport for MCP server
|
||||
const transport = new this.StdioTransport({
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
// Configure the transport for MCP server
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
||||
requestInit: { headers },
|
||||
});
|
||||
|
||||
// Connect to the MCP server
|
||||
|
||||
130
apps/webapp/app/trigger/utils/message-utils.ts
Normal file
130
apps/webapp/app/trigger/utils/message-utils.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { type Message } from "@core/types";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export const createIntegrationAccount = async ({
|
||||
integrationDefinitionId,
|
||||
userId,
|
||||
accountId,
|
||||
config,
|
||||
settings,
|
||||
workspaceId,
|
||||
}: {
|
||||
integrationDefinitionId: string;
|
||||
userId: string;
|
||||
accountId: string;
|
||||
workspaceId: string;
|
||||
config?: Record<string, any>;
|
||||
settings?: Record<string, any>;
|
||||
}) => {
|
||||
return prisma.integrationAccount.create({
|
||||
data: {
|
||||
accountId,
|
||||
integrationDefinitionId,
|
||||
integratedById: userId,
|
||||
integrationConfiguration: config || {},
|
||||
settings: settings || {},
|
||||
isActive: true,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const saveMCPConfig = async ({
|
||||
integrationAccountId,
|
||||
config,
|
||||
}: {
|
||||
integrationAccountId: string;
|
||||
config: any;
|
||||
}) => {
|
||||
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integrationAccount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const integrationConfig = integrationAccount.integrationConfiguration as any;
|
||||
|
||||
return prisma.integrationAccount.update({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
},
|
||||
data: {
|
||||
integrationConfiguration: {
|
||||
...integrationConfig,
|
||||
mcp: config,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const saveIntegrationAccountState = async ({
|
||||
messages,
|
||||
integrationAccountId,
|
||||
}: {
|
||||
messages: Message[];
|
||||
integrationAccountId: string;
|
||||
}) => {
|
||||
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = integrationAccount?.settings as any;
|
||||
const state = settings.state;
|
||||
|
||||
return Promise.all(
|
||||
messages.map(async (message) => {
|
||||
return await prisma.integrationAccount.update({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
},
|
||||
data: {
|
||||
settings: {
|
||||
...settings,
|
||||
state: {
|
||||
...state,
|
||||
...message.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const createActivities = async ({
|
||||
integrationAccountId,
|
||||
messages,
|
||||
}: {
|
||||
integrationAccountId: string;
|
||||
messages: Message[];
|
||||
userId: string;
|
||||
}) => {
|
||||
const integrationAccount = await prisma.integrationAccount.findUnique({
|
||||
where: {
|
||||
id: integrationAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integrationAccount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await prisma.activity.createMany({
|
||||
data: messages.map((message) => {
|
||||
return {
|
||||
text: message.data.text,
|
||||
sourceURL: message.data.sourceURL,
|
||||
integrationAccountId,
|
||||
workspaceId: integrationAccount?.workspaceId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
@ -197,74 +197,16 @@ export const init = async ({ payload }: { payload: InitChatPayload }) => {
|
||||
|
||||
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;
|
||||
// Create MCP server for each integration account
|
||||
const mcpServers: string[] = integrationAccounts
|
||||
.map((account) => {
|
||||
const integrationConfig = account.integrationConfiguration as any;
|
||||
if (integrationConfig.mcp) {
|
||||
return account.integrationDefinition.slug;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to configure MCP for ${account.integrationDefinition?.slug}:`,
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((slug): slug is string => slug !== undefined);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
@ -273,6 +215,7 @@ export const init = async ({ payload }: { payload: InitChatPayload }) => {
|
||||
token: pat.token,
|
||||
userId: user?.id,
|
||||
userName: user?.name,
|
||||
mcpServers,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
73
apps/webapp/app/utils/proxy.server.ts
Normal file
73
apps/webapp/app/utils/proxy.server.ts
Normal file
@ -0,0 +1,73 @@
|
||||
export async function proxyRequest(
|
||||
request: Request,
|
||||
targetUrl: string,
|
||||
token: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const targetURL = new URL(targetUrl);
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
// Copy relevant headers from the original request
|
||||
const headersToProxy = [
|
||||
"content-type",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"accept-language",
|
||||
"accept-encoding",
|
||||
"mcp-session-id",
|
||||
"last-event-id",
|
||||
];
|
||||
|
||||
headersToProxy.forEach((headerName) => {
|
||||
const value = request.headers.get(headerName);
|
||||
if (value) {
|
||||
headers.set(headerName, value);
|
||||
}
|
||||
});
|
||||
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const body =
|
||||
request.method !== "GET" && request.method !== "HEAD"
|
||||
? await request.arrayBuffer()
|
||||
: undefined;
|
||||
|
||||
const response = await fetch(targetURL.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
// Create response headers, excluding hop-by-hop headers
|
||||
const responseHeaders = new Headers();
|
||||
const headersToExclude = [
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
if (!headersToExclude.includes(key.toLowerCase())) {
|
||||
responseHeaders.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Proxy request failed:", error);
|
||||
return new Response(JSON.stringify({ error: "Proxy request failed" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -27,8 +27,8 @@ export default defineConfig({
|
||||
build: {
|
||||
extensions: [
|
||||
syncEnvVars(() => ({
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
BACKEND_HOST: process.env.BACKEND_HOST,
|
||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||
API_BASE_URL: process.env.API_BASE_URL as string,
|
||||
})),
|
||||
prismaExtension({
|
||||
schema: "prisma/schema.prisma",
|
||||
|
||||
3
integrations/linear/.gitignore
vendored
Normal file
3
integrations/linear/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
bin
|
||||
node_modules
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export async function integrationCreate(data: any, integrationDefinition: any) {
|
||||
const { api_key } = data;
|
||||
|
||||
const integrationConfiguration = {
|
||||
api_key: api_key,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
settings: {},
|
||||
accountId: 'linear-account', // Linear doesn't have a specific account ID
|
||||
config: integrationConfiguration,
|
||||
integrationDefinitionId: integrationDefinition.id,
|
||||
};
|
||||
|
||||
const integrationAccount = (await axios.post(`/api/v1/integration_account`, payload)).data;
|
||||
return integrationAccount;
|
||||
}
|
||||
@ -1,599 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from 'axios';
|
||||
import { IntegrationAccount } from '@redplanethq/sol-sdk';
|
||||
|
||||
interface LinearActivityCreateParams {
|
||||
url: string;
|
||||
title: string;
|
||||
sourceId: string;
|
||||
sourceURL: string;
|
||||
integrationAccountId: string;
|
||||
}
|
||||
|
||||
interface LinearSettings {
|
||||
lastIssuesSync?: string;
|
||||
lastCommentsSync?: string;
|
||||
lastUserActionsSync?: string;
|
||||
}
|
||||
|
||||
// Event types to track for user activities
|
||||
enum LinearEventType {
|
||||
ISSUE_CREATED = 'issue_created',
|
||||
ISSUE_UPDATED = 'issue_updated',
|
||||
ISSUE_COMMENTED = 'issue_commented',
|
||||
ISSUE_ASSIGNED = 'issue_assigned',
|
||||
ISSUE_STATUS_CHANGED = 'issue_status_changed',
|
||||
ISSUE_COMPLETED = 'issue_completed',
|
||||
ISSUE_REOPENED = 'issue_reopened',
|
||||
USER_MENTIONED = 'user_mentioned',
|
||||
REACTION_ADDED = 'reaction_added',
|
||||
ISSUE_SUBSCRIBED = 'issue_subscribed',
|
||||
ISSUE_PRIORITY_CHANGED = 'issue_priority_changed',
|
||||
PROJECT_UPDATED = 'project_updated',
|
||||
CYCLE_UPDATED = 'cycle_updated',
|
||||
}
|
||||
|
||||
// GraphQL fragments for reuse
|
||||
const USER_FRAGMENT = `
|
||||
fragment UserFields on User {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
`;
|
||||
|
||||
const ISSUE_FRAGMENT = `
|
||||
fragment IssueFields on Issue {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
createdAt
|
||||
updatedAt
|
||||
archivedAt
|
||||
state {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
team {
|
||||
id
|
||||
name
|
||||
}
|
||||
assignee {
|
||||
...UserFields
|
||||
}
|
||||
creator {
|
||||
...UserFields
|
||||
}
|
||||
subscribers {
|
||||
nodes {
|
||||
...UserFields
|
||||
}
|
||||
}
|
||||
priority
|
||||
}
|
||||
${USER_FRAGMENT}
|
||||
`;
|
||||
|
||||
const COMMENT_FRAGMENT = `
|
||||
fragment CommentFields on Comment {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
...UserFields
|
||||
}
|
||||
issue {
|
||||
...IssueFields
|
||||
}
|
||||
}
|
||||
${USER_FRAGMENT}
|
||||
${ISSUE_FRAGMENT}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Creates an activity in the system based on Linear data
|
||||
*/
|
||||
async function createActivity(params: LinearActivityCreateParams) {
|
||||
try {
|
||||
// This would call the Sol SDK to create an activity
|
||||
console.log(`Creating activity: ${params.title}`);
|
||||
// Would be implemented via Sol SDK similar to GitHub integration
|
||||
} catch (error) {
|
||||
console.error('Error creating activity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user information from Linear
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string) {
|
||||
try {
|
||||
const query = `
|
||||
query {
|
||||
viewer {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{ query },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.viewer;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recent issues relevant to the user (created, assigned, or subscribed)
|
||||
*/
|
||||
async function fetchRecentIssues(accessToken: string, lastSyncTime: string) {
|
||||
try {
|
||||
const query = `
|
||||
query RecentIssues($lastSyncTime: DateTime) {
|
||||
issues(
|
||||
filter: {
|
||||
updatedAt: { gt: $lastSyncTime }
|
||||
},
|
||||
first: 50,
|
||||
orderBy: updatedAt
|
||||
) {
|
||||
nodes {
|
||||
...IssueFields
|
||||
history {
|
||||
nodes {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
fromStateId
|
||||
toStateId
|
||||
fromAssigneeId
|
||||
toAssigneeId
|
||||
fromPriority
|
||||
toPriority
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${ISSUE_FRAGMENT}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{
|
||||
query,
|
||||
variables: {
|
||||
lastSyncTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.issues;
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent issues:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recent comments on issues relevant to the user
|
||||
*/
|
||||
async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
|
||||
try {
|
||||
const query = `
|
||||
query RecentComments($lastSyncTime: DateTime) {
|
||||
comments(
|
||||
filter: {
|
||||
updatedAt: { gt: $lastSyncTime }
|
||||
},
|
||||
first: 50,
|
||||
orderBy: updatedAt
|
||||
) {
|
||||
nodes {
|
||||
...CommentFields
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${COMMENT_FRAGMENT}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{
|
||||
query,
|
||||
variables: {
|
||||
lastSyncTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.comments;
|
||||
} catch (error) {
|
||||
console.error('Error fetching recent comments:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process issue activities and create appropriate activity records
|
||||
*/
|
||||
async function processIssueActivities(
|
||||
issues: any[],
|
||||
userId: string,
|
||||
integrationAccount: IntegrationAccount,
|
||||
isCreator: boolean = false,
|
||||
) {
|
||||
const activities = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
// Skip issues that don't involve the user
|
||||
const isAssignee = issue.assignee?.id === userId;
|
||||
const isCreatedByUser = issue.creator?.id === userId;
|
||||
|
||||
// Check if user is subscribed to the issue
|
||||
const isSubscribed =
|
||||
issue.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false;
|
||||
|
||||
if (!isAssignee && !isCreatedByUser && !isCreator && !isSubscribed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process new issues created by the user
|
||||
if (isCreatedByUser) {
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title: `You created issue ${issue.identifier}: ${issue.title}`,
|
||||
sourceId: `linear-issue-created-${issue.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Process issues assigned to the user (if not created by them)
|
||||
if (isAssignee && !isCreatedByUser) {
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title: `${issue.creator?.name || 'Someone'} assigned you issue ${issue.identifier}: ${issue.title}`,
|
||||
sourceId: `linear-issue-assigned-${issue.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Process issues where the user is subscribed (if not creator or assignee)
|
||||
if (isSubscribed && !isCreatedByUser && !isAssignee) {
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title: `Update on issue ${issue.identifier} you're subscribed to: ${issue.title}`,
|
||||
sourceId: `linear-issue-subscribed-${issue.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Process status changes
|
||||
if (issue.history && issue.history.nodes) {
|
||||
for (const historyItem of issue.history.nodes) {
|
||||
if (historyItem.toStateId && historyItem.fromStateId !== historyItem.toStateId) {
|
||||
// Skip if not relevant to the user
|
||||
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateType = issue.state?.type;
|
||||
let eventType = LinearEventType.ISSUE_STATUS_CHANGED;
|
||||
let statusText = `moved to ${issue.state?.name || 'a new status'}`;
|
||||
|
||||
// Special handling for completion and reopening
|
||||
if (stateType === 'completed') {
|
||||
eventType = LinearEventType.ISSUE_COMPLETED;
|
||||
statusText = 'marked as completed';
|
||||
} else if (stateType === 'canceled') {
|
||||
statusText = 'canceled';
|
||||
} else if (historyItem.fromStateId && !historyItem.toStateId) {
|
||||
eventType = LinearEventType.ISSUE_REOPENED;
|
||||
statusText = 'reopened';
|
||||
}
|
||||
|
||||
let title;
|
||||
if (isCreatedByUser || isAssignee) {
|
||||
title = `You ${statusText} issue ${issue.identifier}: ${issue.title}`;
|
||||
} else if (isSubscribed) {
|
||||
title = `Issue ${issue.identifier} you're subscribed to was ${statusText}: ${issue.title}`;
|
||||
} else {
|
||||
title = `${issue.assignee?.name || 'Someone'} ${statusText} issue ${issue.identifier}: ${issue.title}`;
|
||||
}
|
||||
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title,
|
||||
sourceId: `linear-${eventType}-${issue.id}-${historyItem.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Process priority changes
|
||||
if (historyItem.toPriority && historyItem.fromPriority !== historyItem.toPriority) {
|
||||
// Skip if not relevant to the user
|
||||
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const priorityMap: Record<number, string> = {
|
||||
0: 'No priority',
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const newPriority = priorityMap[historyItem.toPriority] || 'a new priority';
|
||||
|
||||
let title;
|
||||
if (isCreatedByUser) {
|
||||
title = `You changed priority of issue ${issue.identifier} to ${newPriority}`;
|
||||
} else if (isAssignee) {
|
||||
title = `${issue.creator?.name || 'Someone'} changed priority of your assigned issue ${issue.identifier} to ${newPriority}`;
|
||||
} else if (isSubscribed) {
|
||||
title = `Priority of issue ${issue.identifier} you're subscribed to changed to ${newPriority}`;
|
||||
} else {
|
||||
title = `${issue.creator?.name || 'Someone'} changed priority of issue ${issue.identifier} to ${newPriority}`;
|
||||
}
|
||||
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title,
|
||||
sourceId: `linear-issue-priority-${issue.id}-${historyItem.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Process assignment changes
|
||||
if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) {
|
||||
// Only relevant if user is newly assigned or is the creator
|
||||
if (historyItem.toAssigneeId !== userId && !isCreatedByUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title =
|
||||
historyItem.toAssigneeId === userId
|
||||
? `You were assigned issue ${issue.identifier}: ${issue.title}`
|
||||
: `You assigned issue ${issue.identifier} to ${issue.assignee?.name || 'someone'}`;
|
||||
|
||||
activities.push({
|
||||
url: `https://api.linear.app/issue/${issue.id}`,
|
||||
title,
|
||||
sourceId: `linear-issue-reassigned-${issue.id}-${historyItem.id}`,
|
||||
sourceURL: issue.url,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing issue ${issue.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create activities in the system
|
||||
for (const activity of activities) {
|
||||
await createActivity(activity);
|
||||
}
|
||||
|
||||
return activities.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process comment activities and create appropriate activity records
|
||||
*/
|
||||
async function processCommentActivities(
|
||||
comments: any[],
|
||||
userId: string,
|
||||
integrationAccount: IntegrationAccount,
|
||||
) {
|
||||
const activities = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
const isCommenter = comment.user?.id === userId;
|
||||
const isIssueCreator = comment.issue?.creator?.id === userId;
|
||||
const isAssignee = comment.issue?.assignee?.id === userId;
|
||||
|
||||
// Check if user is subscribed to the issue
|
||||
const isSubscribed =
|
||||
comment.issue?.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) ||
|
||||
false;
|
||||
|
||||
// Skip if not relevant to user
|
||||
if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed) {
|
||||
// TODO: Check for mentions in the comment body
|
||||
continue;
|
||||
}
|
||||
|
||||
let title;
|
||||
let sourceId;
|
||||
|
||||
if (isCommenter) {
|
||||
// Comment created by the user
|
||||
title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
|
||||
sourceId = `linear-comment-created-${comment.id}`;
|
||||
} else if (isAssignee || isIssueCreator || isSubscribed) {
|
||||
// Comment on issue where user is assignee, creator, or subscriber
|
||||
let relation = 'an issue';
|
||||
if (isAssignee) {
|
||||
relation = 'your assigned issue';
|
||||
} else if (isIssueCreator) {
|
||||
relation = 'your issue';
|
||||
} else if (isSubscribed) {
|
||||
relation = "an issue you're subscribed to";
|
||||
}
|
||||
title = `${comment.user?.name || 'Someone'} commented on ${relation} ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
|
||||
sourceId = `linear-comment-received-${comment.id}`;
|
||||
}
|
||||
|
||||
if (title && sourceId) {
|
||||
activities.push({
|
||||
url: `https://api.linear.app/comment/${comment.id}`,
|
||||
title,
|
||||
sourceId,
|
||||
sourceURL: `${comment.issue.url}#comment-${comment.id}`,
|
||||
integrationAccountId: integrationAccount.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing comment ${comment.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create activities in the system
|
||||
for (const activity of activities) {
|
||||
await createActivity(activity);
|
||||
}
|
||||
|
||||
return activities.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to truncate text with ellipsis
|
||||
*/
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get default sync time (24 hours ago)
|
||||
*/
|
||||
function getDefaultSyncTime(): string {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to handle scheduled sync for Linear integration
|
||||
*/
|
||||
export async function handleSchedule(integrationAccount: IntegrationAccount) {
|
||||
try {
|
||||
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!integrationConfiguration?.accessToken) {
|
||||
console.error('No access token found for Linear integration');
|
||||
return { message: 'No access token found' };
|
||||
}
|
||||
|
||||
// Get settings or initialize if not present
|
||||
const settings = (integrationAccount.settings || {}) as LinearSettings;
|
||||
|
||||
// Default to 24 hours ago if no last sync times
|
||||
const lastIssuesSync = settings.lastIssuesSync || getDefaultSyncTime();
|
||||
const lastCommentsSync = settings.lastCommentsSync || getDefaultSyncTime();
|
||||
|
||||
// Fetch user info to identify activities relevant to them
|
||||
const user = await fetchUserInfo(integrationConfiguration.accessToken);
|
||||
|
||||
if (!user || !user.id) {
|
||||
console.error('Failed to fetch user info from Linear');
|
||||
return { message: 'Failed to fetch user info' };
|
||||
}
|
||||
|
||||
// Process all issue activities (created, assigned, updated, etc.)
|
||||
let issueCount = 0;
|
||||
try {
|
||||
const issues = await fetchRecentIssues(integrationConfiguration.accessToken, lastIssuesSync);
|
||||
if (issues && issues.nodes) {
|
||||
issueCount = await processIssueActivities(issues.nodes, user.id, integrationAccount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing issues:', error);
|
||||
}
|
||||
|
||||
// Process all comment activities
|
||||
let commentCount = 0;
|
||||
try {
|
||||
const comments = await fetchRecentComments(
|
||||
integrationConfiguration.accessToken,
|
||||
lastCommentsSync,
|
||||
);
|
||||
if (comments && comments.nodes) {
|
||||
commentCount = await processCommentActivities(comments.nodes, user.id, integrationAccount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing comments:', error);
|
||||
}
|
||||
|
||||
// TODO: Implement additional activity types:
|
||||
// - Reaction tracking
|
||||
// - PR/Merge request tracking (if supported by Linear)
|
||||
// - Project and cycle updates
|
||||
// - Team updates and notifications
|
||||
// - Mention detection in descriptions and comments
|
||||
|
||||
// Update last sync times
|
||||
const newSyncTime = new Date().toISOString();
|
||||
|
||||
// Save new settings
|
||||
integrationAccount.settings = {
|
||||
...settings,
|
||||
lastIssuesSync: newSyncTime,
|
||||
lastCommentsSync: newSyncTime,
|
||||
};
|
||||
|
||||
return {
|
||||
message: `Synced ${issueCount} issues and ${commentCount} comments from Linear`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in Linear scheduled sync:', error);
|
||||
return {
|
||||
message: `Error syncing Linear activities: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main handler for the scheduled sync event
|
||||
*/
|
||||
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
|
||||
return handleSchedule(integrationAccount);
|
||||
}
|
||||
@ -23,10 +23,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-replace": "^5.0.7",
|
||||
"@types/node": "^18.0.20",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@ -37,10 +33,6 @@
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^4.28.1",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"rollup-plugin-typescript2": "^0.34.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^4.7.2",
|
||||
"tsup": "^8.0.1",
|
||||
@ -66,6 +58,6 @@
|
||||
"commander": "^12.0.0",
|
||||
"openai": "^4.0.0",
|
||||
"react-query": "^3.39.3",
|
||||
"@redplanethq/sdk": "0.1.0"
|
||||
"@redplanethq/sdk": "0.1.1"
|
||||
}
|
||||
}
|
||||
9605
integrations/linear/pnpm-lock.yaml
generated
9605
integrations/linear/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,71 +0,0 @@
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import nodePolyfills from 'rollup-plugin-node-polyfills';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import { babel } from '@rollup/plugin-babel';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
|
||||
const frontendPlugins = [
|
||||
postcss({
|
||||
inject: true, // Inject CSS as JS, making it part of the bundle
|
||||
minimize: true, // Minify CSS
|
||||
}),
|
||||
json(),
|
||||
resolve({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }),
|
||||
commonjs({
|
||||
include: /\/node_modules\//,
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: 'tsconfig.frontend.json',
|
||||
}),
|
||||
babel({
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
|
||||
}),
|
||||
terser(),
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
input: 'frontend/index.tsx',
|
||||
external: ['react', 'react-dom', '@tegonhq/ui', 'axios', 'react-query'],
|
||||
output: [
|
||||
{
|
||||
file: 'dist/frontend/index.js',
|
||||
sourcemap: true,
|
||||
format: 'cjs',
|
||||
exports: 'named',
|
||||
preserveModules: false,
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
],
|
||||
plugins: frontendPlugins,
|
||||
},
|
||||
{
|
||||
input: 'backend/index.ts',
|
||||
external: ['axios'],
|
||||
output: [
|
||||
{
|
||||
file: 'dist/backend/index.js',
|
||||
sourcemap: true,
|
||||
format: 'cjs',
|
||||
exports: 'named',
|
||||
preserveModules: false,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
nodePolyfills(),
|
||||
json(),
|
||||
resolve({ extensions: ['.js', '.ts'] }),
|
||||
commonjs({
|
||||
include: /\/node_modules\//,
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: 'tsconfig.json',
|
||||
}),
|
||||
terser(),
|
||||
],
|
||||
},
|
||||
];
|
||||
66
integrations/linear/src/account-create.ts
Normal file
66
integrations/linear/src/account-create.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export async function integrationCreate({ apiKey }: { apiKey: string }) {
|
||||
// Fetch the Linear user info using the GraphQL API
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: 'query { viewer { id name email } }',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Linear user: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const viewer = result?.data?.viewer;
|
||||
const userId = viewer?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Could not extract userId from Linear GraphQL API response');
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'account',
|
||||
data: {
|
||||
settings: {
|
||||
user: {
|
||||
id: viewer.id,
|
||||
name: viewer.name,
|
||||
email: viewer.email,
|
||||
},
|
||||
},
|
||||
accountId: userId,
|
||||
config: { apiKey },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface MCPIntegrationCreateData {
|
||||
oauthResponse: {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
mcp: boolean;
|
||||
}
|
||||
|
||||
export async function integrationCreateForMCP(data: MCPIntegrationCreateData) {
|
||||
return [
|
||||
{
|
||||
type: 'account',
|
||||
data: {
|
||||
mcp: true,
|
||||
config: data.oauthResponse,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { handleSchedule } from 'schedule';
|
||||
import { integrationCreate } from './account-create';
|
||||
import { handleSchedule } from './schedule';
|
||||
import { integrationCreate, integrationCreateForMCP } from './account-create';
|
||||
|
||||
import {
|
||||
IntegrationCLI,
|
||||
@ -11,10 +11,12 @@ import {
|
||||
export async function run(eventPayload: IntegrationEventPayload) {
|
||||
switch (eventPayload.event) {
|
||||
case IntegrationEventType.SETUP:
|
||||
return await integrationCreate(eventPayload.eventBody, eventPayload.integrationDefinition);
|
||||
return eventPayload.eventBody.mcp
|
||||
? await integrationCreateForMCP(eventPayload.eventBody)
|
||||
: await integrationCreate(eventPayload.eventBody);
|
||||
|
||||
case IntegrationEventType.SYNC:
|
||||
return await handleSchedule(eventPayload.config);
|
||||
return await handleSchedule(eventPayload.config, eventPayload.state);
|
||||
|
||||
default:
|
||||
return { message: `The event payload type is ${eventPayload.event}` };
|
||||
522
integrations/linear/src/schedule.ts
Normal file
522
integrations/linear/src/schedule.ts
Normal file
@ -0,0 +1,522 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import axios from 'axios';
|
||||
|
||||
interface LinearActivityCreateParams {
|
||||
text: string;
|
||||
sourceURL: string;
|
||||
}
|
||||
|
||||
interface LinearSettings {
|
||||
lastIssuesSync?: string;
|
||||
lastCommentsSync?: string;
|
||||
lastUserActionsSync?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity message based on Linear data
|
||||
*/
|
||||
function createActivityMessage(params: LinearActivityCreateParams) {
|
||||
return {
|
||||
type: 'activity',
|
||||
data: {
|
||||
text: params.text,
|
||||
sourceURL: params.sourceURL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user information from Linear
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string) {
|
||||
try {
|
||||
const query = `
|
||||
query {
|
||||
viewer {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{ query },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.viewer;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recent issues relevant to the user (created, assigned, or subscribed)
|
||||
*/
|
||||
async function fetchRecentIssues(accessToken: string, lastSyncTime: string) {
|
||||
try {
|
||||
const query = `
|
||||
query RecentIssues($lastSyncTime: DateTimeOrDuration) {
|
||||
issues(
|
||||
filter: {
|
||||
updatedAt: { gt: $lastSyncTime }
|
||||
},
|
||||
first: 50,
|
||||
orderBy: updatedAt
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
createdAt
|
||||
updatedAt
|
||||
state {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
team {
|
||||
id
|
||||
name
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
creator {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
subscribers {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
}
|
||||
priority
|
||||
history {
|
||||
nodes {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
fromStateId
|
||||
toStateId
|
||||
fromAssigneeId
|
||||
toAssigneeId
|
||||
fromPriority
|
||||
toPriority
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{
|
||||
query,
|
||||
variables: {
|
||||
lastSyncTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.issues;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recent comments on issues relevant to the user
|
||||
*/
|
||||
async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
|
||||
try {
|
||||
const query = `
|
||||
query RecentComments($lastSyncTime: DateTimeOrDuration) {
|
||||
comments(
|
||||
filter: {
|
||||
updatedAt: { gt: $lastSyncTime }
|
||||
},
|
||||
first: 50,
|
||||
orderBy: updatedAt
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
updatedAt
|
||||
user {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
issue {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
creator {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
assignee {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
subscribers {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
'https://api.linear.app/graphql',
|
||||
{
|
||||
query,
|
||||
variables: {
|
||||
lastSyncTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.data.comments;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process issue activities and create appropriate activity records
|
||||
*/
|
||||
async function processIssueActivities(issues: any[], userId: string) {
|
||||
const activities = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
try {
|
||||
// Skip issues that don't involve the user
|
||||
const isAssignee = issue.assignee?.id === userId;
|
||||
const isCreatedByUser = issue.creator?.id === userId;
|
||||
|
||||
// Check if user is subscribed to the issue
|
||||
const isSubscribed =
|
||||
issue.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) || false;
|
||||
|
||||
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process history to determine what actually changed
|
||||
let activityCreated = false;
|
||||
|
||||
// Process assignment changes first (highest priority)
|
||||
if (issue.history && issue.history.nodes) {
|
||||
for (const historyItem of issue.history.nodes) {
|
||||
if (historyItem.toAssigneeId && historyItem.fromAssigneeId !== historyItem.toAssigneeId) {
|
||||
if (historyItem.toAssigneeId === userId) {
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: `${issue.identifier} (${issue.title}) Issue assigned to you`,
|
||||
sourceURL: issue.url,
|
||||
}),
|
||||
);
|
||||
activityCreated = true;
|
||||
break;
|
||||
} else if (isCreatedByUser && historyItem.fromAssigneeId === userId) {
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: `${issue.identifier} (${issue.title}) Issue unassigned from you`,
|
||||
sourceURL: issue.url,
|
||||
}),
|
||||
);
|
||||
activityCreated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no assignment change, check for status changes
|
||||
if (!activityCreated && issue.history && issue.history.nodes) {
|
||||
for (const historyItem of issue.history.nodes) {
|
||||
if (historyItem.toStateId && historyItem.fromStateId !== historyItem.toStateId) {
|
||||
if (!isAssignee && !isCreatedByUser && !isSubscribed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stateType = issue.state?.type;
|
||||
let statusText = `moved to ${issue.state?.name || 'a new status'}`;
|
||||
|
||||
if (stateType === 'completed') {
|
||||
statusText = 'completed';
|
||||
} else if (stateType === 'canceled') {
|
||||
statusText = 'canceled';
|
||||
}
|
||||
|
||||
let title;
|
||||
if (isCreatedByUser || isAssignee) {
|
||||
title = `${issue.identifier} (${issue.title}) Issue ${statusText}`;
|
||||
} else {
|
||||
title = `${issue.identifier} (${issue.title}) Issue ${statusText}`;
|
||||
}
|
||||
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: title,
|
||||
sourceURL: issue.url,
|
||||
}),
|
||||
);
|
||||
activityCreated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no history changes, check if it's a new issue creation
|
||||
if (!activityCreated && isCreatedByUser) {
|
||||
// Only create activity if issue was created recently (within sync window)
|
||||
const createdAt = new Date(issue.createdAt);
|
||||
const updatedAt = new Date(issue.updatedAt);
|
||||
|
||||
// If created and updated times are very close, it's likely a new issue
|
||||
if (Math.abs(createdAt.getTime() - updatedAt.getTime()) < 60000) {
|
||||
// within 1 minute
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: `${issue.identifier} (${issue.title}) Issue created`,
|
||||
sourceURL: issue.url,
|
||||
}),
|
||||
);
|
||||
activityCreated = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process comment activities and create appropriate activity records
|
||||
*/
|
||||
async function processCommentActivities(comments: any[], userId: string, userInfo: any) {
|
||||
const activities = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
const isCommenter = comment.user?.id === userId;
|
||||
const isIssueCreator = comment.issue?.creator?.id === userId;
|
||||
const isAssignee = comment.issue?.assignee?.id === userId;
|
||||
|
||||
// Check if user is subscribed to the issue
|
||||
const isSubscribed =
|
||||
comment.issue?.subscribers?.nodes?.some((subscriber: any) => subscriber.id === userId) ||
|
||||
false;
|
||||
|
||||
// Check for mentions in the comment body
|
||||
const isMentioned = checkForUserMentions(comment.body, userInfo);
|
||||
|
||||
// Skip if not relevant to user
|
||||
if (!isCommenter && !isIssueCreator && !isAssignee && !isSubscribed && !isMentioned) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title;
|
||||
|
||||
if (isCommenter) {
|
||||
// Comment created by the user
|
||||
title = `You commented on issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
|
||||
} else if (isMentioned) {
|
||||
// User was mentioned in the comment
|
||||
title = `${comment.user?.name || 'Someone'} mentioned you in issue ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
|
||||
} else if (isAssignee || isIssueCreator || isSubscribed) {
|
||||
// Comment on issue where user is assignee, creator, or subscriber
|
||||
let relation = 'an issue';
|
||||
if (isAssignee) {
|
||||
relation = 'your assigned issue';
|
||||
} else if (isIssueCreator) {
|
||||
relation = 'your issue';
|
||||
} else if (isSubscribed) {
|
||||
relation = "an issue you're subscribed to";
|
||||
}
|
||||
title = `${comment.user?.name || 'Someone'} commented on ${relation} ${comment.issue.identifier}: ${truncateText(comment.body, 100)}`;
|
||||
}
|
||||
|
||||
if (title) {
|
||||
activities.push(
|
||||
createActivityMessage({
|
||||
text: title,
|
||||
sourceURL: `${comment.issue.url}#comment-${comment.id}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check for user mentions in text
|
||||
*/
|
||||
function checkForUserMentions(text: string, userInfo: any): boolean {
|
||||
if (!text || !userInfo) return false;
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Check for @username, @display name, or @email mentions
|
||||
const mentionPatterns = [
|
||||
userInfo.name && `@${userInfo.name.toLowerCase()}`,
|
||||
userInfo.displayName && `@${userInfo.displayName.toLowerCase()}`,
|
||||
userInfo.email && `@${userInfo.email.toLowerCase()}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return mentionPatterns.some((pattern) => lowerText.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to truncate text with ellipsis
|
||||
*/
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get default sync time (24 hours ago)
|
||||
*/
|
||||
function getDefaultSyncTime(): string {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return yesterday.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to handle scheduled sync for Linear integration
|
||||
*/
|
||||
export async function handleSchedule(config: any, state: any) {
|
||||
try {
|
||||
const integrationConfiguration = config;
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!integrationConfiguration?.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get settings or initialize if not present
|
||||
let settings = (state || {}) as LinearSettings;
|
||||
|
||||
// Default to 24 hours ago if no last sync times
|
||||
const lastIssuesSync = settings.lastIssuesSync || getDefaultSyncTime();
|
||||
const lastCommentsSync = settings.lastCommentsSync || getDefaultSyncTime();
|
||||
|
||||
// Fetch user info to identify activities relevant to them
|
||||
let user;
|
||||
try {
|
||||
user = await fetchUserInfo(integrationConfiguration.accessToken);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!user || !user.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collect all messages
|
||||
const messages = [];
|
||||
|
||||
// Process all issue activities (created, assigned, updated, etc.)
|
||||
try {
|
||||
const issues = await fetchRecentIssues(integrationConfiguration.accessToken, lastIssuesSync);
|
||||
if (issues && issues.nodes) {
|
||||
const issueActivities = await processIssueActivities(issues.nodes, user.id);
|
||||
messages.push(...issueActivities);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
|
||||
// Process all comment activities
|
||||
try {
|
||||
const comments = await fetchRecentComments(
|
||||
integrationConfiguration.accessToken,
|
||||
lastCommentsSync,
|
||||
);
|
||||
if (comments && comments.nodes) {
|
||||
const commentActivities = await processCommentActivities(comments.nodes, user.id, user);
|
||||
messages.push(...commentActivities);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore errors to prevent stdout pollution
|
||||
}
|
||||
|
||||
// Update last sync times
|
||||
const newSyncTime = new Date().toISOString();
|
||||
|
||||
// Add state message for saving settings
|
||||
messages.push({
|
||||
type: 'state',
|
||||
data: {
|
||||
...settings,
|
||||
lastIssuesSync: newSyncTime,
|
||||
lastCommentsSync: newSyncTime,
|
||||
},
|
||||
});
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main handler for the scheduled sync event
|
||||
*/
|
||||
export async function scheduleHandler(config: any, state: any) {
|
||||
return handleSchedule(config, state);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["frontend/**/*"],
|
||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
||||
"types": ["typePatches"]
|
||||
}
|
||||
@ -1,17 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "backend",
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
@ -25,7 +27,7 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["backend/**/*"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
|
||||
"types": ["typePatches"]
|
||||
}
|
||||
|
||||
20
integrations/linear/tsup.config.ts
Normal file
20
integrations/linear/tsup.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { dependencies } from './package.json';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs'], // or esm if you're using that
|
||||
bundle: true,
|
||||
target: 'node16',
|
||||
outDir: 'bin',
|
||||
splitting: false,
|
||||
shims: true,
|
||||
clean: true,
|
||||
name: 'linear',
|
||||
platform: 'node',
|
||||
legacyOutput: false,
|
||||
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
|
||||
treeshake: {
|
||||
preset: 'recommended',
|
||||
},
|
||||
});
|
||||
@ -23,10 +23,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-replace": "^5.0.7",
|
||||
"@types/node": "^18.0.20",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@ -37,10 +33,6 @@
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"rollup": "^4.28.1",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"rollup-plugin-typescript2": "^0.34.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^4.7.2",
|
||||
"tsup": "^8.0.1",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
@ -9,16 +8,12 @@
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"baseUrl": "frontend",
|
||||
"allowJs": false,
|
||||
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"removeComments": true,
|
||||
|
||||
@ -82,12 +82,17 @@ export function createMCPProxy(
|
||||
);
|
||||
}
|
||||
|
||||
// Extract session ID and last event ID from incoming request
|
||||
const clientSessionId = request.headers.get("Mcp-Session-Id");
|
||||
const lastEventId = request.headers.get("Last-Event-Id");
|
||||
|
||||
// Create remote transport (connects to the MCP server) FIRST
|
||||
const serverTransport = await createRemoteTransport(
|
||||
credentials.serverUrl,
|
||||
credentials,
|
||||
config.redirectUrl,
|
||||
config.transportStrategy || "sse-first"
|
||||
config.transportStrategy || "sse-first",
|
||||
{ sessionId: clientSessionId, lastEventId } // Pass both session and event IDs
|
||||
);
|
||||
|
||||
// Start server transport and wait for connection
|
||||
@ -116,33 +121,8 @@ export function createMCPProxy(
|
||||
bridgeOptions
|
||||
);
|
||||
|
||||
// Set up timeout
|
||||
const timeoutId = config.timeout
|
||||
? setTimeout(() => {
|
||||
bridge?.close().catch(console.error);
|
||||
if (!resolve) return;
|
||||
resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Request timeout",
|
||||
}),
|
||||
{
|
||||
status: 408,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
);
|
||||
}, config.timeout)
|
||||
: null;
|
||||
|
||||
// Start only the client transport (server is already started)
|
||||
await clientTransport.start();
|
||||
|
||||
// Clean up after a reasonable time (since HTTP is request/response)
|
||||
setTimeout(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
bridge?.close().catch(console.error);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("MCP Transport Proxy Error:", error);
|
||||
|
||||
@ -171,47 +151,40 @@ export function createMCPProxy(
|
||||
serverUrl: string,
|
||||
credentials: StoredCredentials,
|
||||
redirectUrl: string,
|
||||
transportStrategy: TransportStrategy = "sse-first"
|
||||
transportStrategy: TransportStrategy = "sse-first",
|
||||
clientHeaders?: { sessionId?: string | null; lastEventId?: string | null }
|
||||
): Promise<SSEClientTransport | StreamableHTTPClientTransport> {
|
||||
// Create auth provider with stored credentials using common factory
|
||||
const authProvider = await createAuthProviderForProxy(serverUrl, credentials, redirectUrl);
|
||||
|
||||
const url = new URL(serverUrl);
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${credentials.tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
...config.headers,
|
||||
};
|
||||
|
||||
// Add session and event headers if provided
|
||||
if (clientHeaders?.sessionId) {
|
||||
headers["Mcp-Session-Id"] = clientHeaders.sessionId;
|
||||
}
|
||||
if (clientHeaders?.lastEventId) {
|
||||
headers["Last-Event-Id"] = clientHeaders.lastEventId;
|
||||
}
|
||||
|
||||
// Create transport based on strategy (don't start yet)
|
||||
let transport: SSEClientTransport | StreamableHTTPClientTransport;
|
||||
|
||||
// For SSE, we need eventSourceInit for authentication
|
||||
const eventSourceInit = {
|
||||
fetch: (url: string | URL, init?: RequestInit) => {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
...headers,
|
||||
Accept: "text/event-stream",
|
||||
} as Record<string, string>,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
switch (transportStrategy) {
|
||||
case "sse-only":
|
||||
transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
});
|
||||
break;
|
||||
|
||||
case "http-only":
|
||||
transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
});
|
||||
break;
|
||||
@ -222,12 +195,10 @@ export function createMCPProxy(
|
||||
transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("SSE transport failed, falling back to HTTP:", error);
|
||||
transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
});
|
||||
}
|
||||
@ -237,7 +208,6 @@ export function createMCPProxy(
|
||||
// Try HTTP first, fallback to SSE on error
|
||||
try {
|
||||
transport = new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
});
|
||||
} catch (error) {
|
||||
@ -245,7 +215,6 @@ export function createMCPProxy(
|
||||
transport = new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@ -180,6 +180,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
|
||||
authorizationUrl.searchParams.set("resource", this.authorizeResource);
|
||||
}
|
||||
|
||||
// Keep it
|
||||
console.log(this.authorizationUrl);
|
||||
|
||||
// Store the URL instead of opening browser
|
||||
|
||||
@ -9,8 +9,8 @@ export function createMCPTransportBridge(
|
||||
serverTransport: Transport,
|
||||
options: {
|
||||
debug?: boolean;
|
||||
onMessage?: (direction: 'client-to-server' | 'server-to-client', message: any) => void;
|
||||
onError?: (error: Error, source: 'client' | 'server') => void;
|
||||
onMessage?: (direction: "client-to-server" | "server-to-client", message: any) => void;
|
||||
onError?: (error: Error, source: "client" | "server") => void;
|
||||
} = {}
|
||||
) {
|
||||
let clientClosed = false;
|
||||
@ -22,24 +22,41 @@ export function createMCPTransportBridge(
|
||||
const logError = debug ? console.error : () => {};
|
||||
|
||||
// Forward messages from client to server
|
||||
clientTransport.onmessage = (message: any) => {
|
||||
log('[Client→Server]', message.method || message.id);
|
||||
onMessage?.('client-to-server', message);
|
||||
|
||||
serverTransport.send(message).catch(error => {
|
||||
logError('Error sending to server:', error);
|
||||
onError?.(error, 'server');
|
||||
clientTransport.onmessage = (message: any, extra: any) => {
|
||||
console.log(JSON.stringify(message));
|
||||
log("[Client→Server]", message.method || message.id);
|
||||
onMessage?.("client-to-server", message);
|
||||
|
||||
// Forward any extra parameters (like resumption tokens) to the server
|
||||
const serverOptions: any = {};
|
||||
if (extra?.relatedRequestId) {
|
||||
serverOptions.relatedRequestId = extra.relatedRequestId;
|
||||
}
|
||||
|
||||
serverTransport.send(message, serverOptions).catch((error) => {
|
||||
logError("Error sending to server:", error);
|
||||
onError?.(error, "server");
|
||||
});
|
||||
};
|
||||
|
||||
// Forward messages from server to client
|
||||
serverTransport.onmessage = (message: any) => {
|
||||
log('[Server→Client]', message.method || message.id);
|
||||
onMessage?.('server-to-client', message);
|
||||
|
||||
clientTransport.send(message).catch(error => {
|
||||
logError('Error sending to client:', error);
|
||||
onError?.(error, 'client');
|
||||
serverTransport.onmessage = (message: any, extra: any) => {
|
||||
console.log(JSON.stringify(message), JSON.stringify(extra));
|
||||
log("[Server→Client]", message.method || message.id);
|
||||
onMessage?.("server-to-client", message);
|
||||
|
||||
// Forward the server's session ID as resumption token to client
|
||||
const clientOptions: any = {};
|
||||
if (serverTransport.sessionId) {
|
||||
clientOptions.resumptionToken = serverTransport.sessionId;
|
||||
}
|
||||
if (extra?.relatedRequestId) {
|
||||
clientOptions.relatedRequestId = extra.relatedRequestId;
|
||||
}
|
||||
|
||||
clientTransport.send(message, clientOptions).catch((error) => {
|
||||
logError("Error sending to client:", error);
|
||||
onError?.(error, "client");
|
||||
});
|
||||
};
|
||||
|
||||
@ -47,30 +64,31 @@ export function createMCPTransportBridge(
|
||||
clientTransport.onclose = () => {
|
||||
if (serverClosed) return;
|
||||
clientClosed = true;
|
||||
log('Client transport closed, closing server transport');
|
||||
serverTransport.close().catch(error => {
|
||||
logError('Error closing server transport:', error);
|
||||
log("Client transport closed, closing server transport");
|
||||
serverTransport.close().catch((error) => {
|
||||
logError("Error closing server transport:", error);
|
||||
});
|
||||
};
|
||||
|
||||
serverTransport.onclose = () => {
|
||||
if (clientClosed) return;
|
||||
serverClosed = true;
|
||||
log('Server transport closed, closing client transport');
|
||||
clientTransport.close().catch(error => {
|
||||
logError('Error closing client transport:', error);
|
||||
console.log("closing");
|
||||
log("Server transport closed, closing client transport");
|
||||
clientTransport.close().catch((error) => {
|
||||
logError("Error closing client transport:", error);
|
||||
});
|
||||
};
|
||||
|
||||
// Error handling
|
||||
clientTransport.onerror = (error: Error) => {
|
||||
logError('Client transport error:', error);
|
||||
onError?.(error, 'client');
|
||||
logError("Client transport error:", error);
|
||||
onError?.(error, "client");
|
||||
};
|
||||
|
||||
serverTransport.onerror = (error: Error) => {
|
||||
logError('Server transport error:', error);
|
||||
onError?.(error, 'server');
|
||||
logError("Server transport error:", error);
|
||||
onError?.(error, "server");
|
||||
};
|
||||
|
||||
return {
|
||||
@ -79,13 +97,10 @@ export function createMCPTransportBridge(
|
||||
*/
|
||||
start: async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
clientTransport.start(),
|
||||
serverTransport.start()
|
||||
]);
|
||||
log('MCP transport bridge started successfully');
|
||||
await Promise.all([clientTransport.start(), serverTransport.start()]);
|
||||
log("MCP transport bridge started successfully");
|
||||
} catch (error) {
|
||||
logError('Error starting transport bridge:', error);
|
||||
logError("Error starting transport bridge:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -95,13 +110,10 @@ export function createMCPTransportBridge(
|
||||
*/
|
||||
close: async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
clientTransport.close(),
|
||||
serverTransport.close()
|
||||
]);
|
||||
log('MCP transport bridge closed successfully');
|
||||
await Promise.all([clientTransport.close(), serverTransport.close()]);
|
||||
log("MCP transport bridge closed successfully");
|
||||
} catch (error) {
|
||||
logError('Error closing transport bridge:', error);
|
||||
logError("Error closing transport bridge:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@ -111,6 +123,6 @@ export function createMCPTransportBridge(
|
||||
*/
|
||||
get isClosed() {
|
||||
return clientClosed || serverClosed;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ export class RemixMCPTransport implements Transport {
|
||||
private request: Request,
|
||||
private sendResponse: (response: Response) => void
|
||||
) {}
|
||||
|
||||
sessionId?: string;
|
||||
setProtocolVersion?: (version: string) => void;
|
||||
|
||||
@ -55,15 +56,18 @@ export class RemixMCPTransport implements Transport {
|
||||
throw new Error("Transport is closed");
|
||||
}
|
||||
|
||||
// Prepare headers
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
// Send the MCP response back as HTTP response
|
||||
const response = new Response(JSON.stringify(message), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
this.sendResponse(response);
|
||||
@ -83,5 +87,7 @@ export class RemixMCPTransport implements Transport {
|
||||
|
||||
onmessage: (message: any) => void = () => {};
|
||||
onclose: () => void = () => {};
|
||||
onerror: (error: Error) => void = () => {};
|
||||
async onerror(error: Error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@redplanethq/sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "CORE Node.JS SDK",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1 +1 @@
|
||||
export { IntegrationCLI } from './integration_cli';
|
||||
export { IntegrationCLI } from './integration-cli';
|
||||
|
||||
@ -56,7 +56,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during setup:', error);
|
||||
@ -83,7 +83,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing data:', error);
|
||||
@ -105,7 +105,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error identifying account:', error);
|
||||
@ -126,7 +126,7 @@ export abstract class IntegrationCLI {
|
||||
data: spec,
|
||||
};
|
||||
// For spec, we keep the single message output for compatibility
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error getting spec:', error);
|
||||
process.exit(1);
|
||||
@ -153,7 +153,7 @@ export abstract class IntegrationCLI {
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
console.log(JSON.stringify(message, null, 2));
|
||||
console.log(JSON.stringify(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sync:', error);
|
||||
@ -56,7 +56,7 @@ export interface Identifier {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export type MessageType = "spec" | "activity" | "state" | "identifier";
|
||||
export type MessageType = "spec" | "activity" | "state" | "identifier" | "account";
|
||||
|
||||
export interface Message {
|
||||
type: MessageType;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user