diff --git a/apps/webapp/app/components/common/page-header.tsx b/apps/webapp/app/components/common/page-header.tsx new file mode 100644 index 0000000..d662bce --- /dev/null +++ b/apps/webapp/app/components/common/page-header.tsx @@ -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 ( +
+ + +
+ ); +} + +export function PageHeader({ + title, + breadcrumbs, + actions, + tabs, + showBackForward = true, +}: PageHeaderProps) { + return ( +
+
+
+ {/* Back/Forward navigation before SidebarTrigger */} + {showBackForward && } + + + {/* Breadcrumbs */} + {breadcrumbs && breadcrumbs.length > 0 ? ( + + ) : ( +

{title}

+ )} + + {/* Tabs */} + {tabs && tabs.length > 0 && ( +
+ {tabs.map((tab) => ( + + ))} +
+ )} +
+ + {/* Actions */} + {actions && actions.length > 0 && ( +
+ {actions.map((action, index) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/conversation/conversation-item.client.tsx b/apps/webapp/app/components/conversation/conversation-item.client.tsx index 5e26217..8b7023e 100644 --- a/apps/webapp/app/components/conversation/conversation-item.client.tsx +++ b/apps/webapp/app/components/conversation/conversation-item.client.tsx @@ -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, }); diff --git a/apps/webapp/app/components/conversation/conversation-list.tsx b/apps/webapp/app/components/conversation/conversation-list.tsx index 2881cbf..0c81a1b 100644 --- a/apps/webapp/app/components/conversation/conversation-list.tsx +++ b/apps/webapp/app/components/conversation/conversation-list.tsx @@ -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 = ({
-

+

{conversation.title || "Untitled Conversation"}

diff --git a/apps/webapp/app/components/conversation/conversation.client.tsx b/apps/webapp/app/components/conversation/conversation.client.tsx index 022fa07..a0d38cf 100644 --- a/apps/webapp/app/components/conversation/conversation.client.tsx +++ b/apps/webapp/app/components/conversation/conversation.client.tsx @@ -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 ( - - +
submitForm(e)} + className="h-[calc(100vh_-_56px)] pt-2" + > +
+
+
+
+

+ Hello {user.name} +

- - submitForm(e)} - className="pt-2" - > -
-
-
-
-

- Hello {user.name} -

+

+ Demo UI: basic conversation to showcase memory integration. +

+
+ + { + 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(); -

- Demo UI: basic conversation to showcase memory integration. -

-
- - { - 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); - }} - /> - -
- -
-
+ 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); + }} + /> +
+
+
- - - +
+
+ ); }; diff --git a/apps/webapp/app/components/editor/conversation-context.tsx b/apps/webapp/app/components/editor/conversation-context.tsx new file mode 100644 index 0000000..217082d --- /dev/null +++ b/apps/webapp/app/components/editor/conversation-context.tsx @@ -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; +} + +export const ConversationContext = React.createContext< + ConversationContextInterface | undefined +>(undefined); diff --git a/apps/webapp/app/components/editor/skill-extension/index.ts b/apps/webapp/app/components/editor/skill-extension/index.ts new file mode 100644 index 0000000..fa6840d --- /dev/null +++ b/apps/webapp/app/components/editor/skill-extension/index.ts @@ -0,0 +1 @@ +export * from './skill-extension'; diff --git a/apps/webapp/app/components/editor/skill-extension/skill-component.tsx b/apps/webapp/app/components/editor/skill-extension/skill-component.tsx new file mode 100644 index 0000000..1f63f85 --- /dev/null +++ b/apps/webapp/app/components/editor/skill-extension/skill-component.tsx @@ -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 ; + }; + + 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 ( + <> +
+ {getIcon()} + {snakeToTitleCase(name)} +
+ + ); + }; + + return ( + {getComponent()} + ); +}; diff --git a/apps/webapp/app/components/editor/skill-extension/skill-extension.ts b/apps/webapp/app/components/editor/skill-extension/skill-extension.ts new file mode 100644 index 0000000..1ff32f0 --- /dev/null +++ b/apps/webapp/app/components/editor/skill-extension/skill-extension.ts @@ -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); + }, +}); diff --git a/apps/webapp/app/components/icon-utils.tsx b/apps/webapp/app/components/icon-utils.tsx index 762276e..cc45617 100644 --- a/apps/webapp/app/components/icon-utils.tsx +++ b/apps/webapp/app/components/icon-utils.tsx @@ -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, diff --git a/apps/webapp/app/components/icons/index.ts b/apps/webapp/app/components/icons/index.ts new file mode 100644 index 0000000..b5a7e4c --- /dev/null +++ b/apps/webapp/app/components/icons/index.ts @@ -0,0 +1,2 @@ +export * from "./slack-icon"; +export * from "./linear-icon"; diff --git a/apps/webapp/app/components/icons/linear-icon.tsx b/apps/webapp/app/components/icons/linear-icon.tsx new file mode 100644 index 0000000..0582b72 --- /dev/null +++ b/apps/webapp/app/components/icons/linear-icon.tsx @@ -0,0 +1,23 @@ +import type { IconProps } from "./types"; + +export function LinearIcon({ size = 18, className }: IconProps) { + return ( + + Linear + + + + + ); +} diff --git a/apps/webapp/app/components/icons/slack-icon.tsx b/apps/webapp/app/components/icons/slack-icon.tsx new file mode 100644 index 0000000..b418f9d --- /dev/null +++ b/apps/webapp/app/components/icons/slack-icon.tsx @@ -0,0 +1,30 @@ +import type { IconProps } from "./types"; + +export function SlackIcon({ size = 18, className }: IconProps) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/components/icons/types.tsx b/apps/webapp/app/components/icons/types.tsx new file mode 100644 index 0000000..fd015c0 --- /dev/null +++ b/apps/webapp/app/components/icons/types.tsx @@ -0,0 +1,6 @@ +export interface IconProps { + size?: number; + className?: string; + color?: string; + onClick?: (event: MouseEvent) => void; +} diff --git a/apps/webapp/app/components/integrations/IntegrationAuthDialog.tsx b/apps/webapp/app/components/integrations/IntegrationAuthDialog.tsx deleted file mode 100644 index aa7aae2..0000000 --- a/apps/webapp/app/components/integrations/IntegrationAuthDialog.tsx +++ /dev/null @@ -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 ( - { - if (open) { - setApiKey(""); - } - onOpenChange?.(open); - }} - > - {children} - - - - Connect to {integration.name} - - {integration.description || - `Connect your ${integration.name} account to enable integration.`} - - - - {/* API Key Authentication */} - {hasApiKey && ( -
-
- - setApiKey(e.target.value)} - /> - {specData?.auth?.api_key?.description && ( -

- {specData.auth.api_key.description} -

- )} -
- - {isLoading || apiKeyFetcher.state === "submitting" - ? "Connecting..." - : "Connect"} - - } - /> -
- )} - - {/* OAuth Authentication */} - {hasOAuth2 && ( -
- -
- )} - - {/* MCP Authentication */} - {hasMCPAuth && ( -
-
-

MCP Authentication

-

- This integration requires MCP (Model Context Protocol) authentication. -

- -
-
- )} - - {/* No authentication method found */} - {!hasApiKey && !hasOAuth2 && !hasMCPAuth && ( -
- This integration doesn't specify an authentication method. -
- )} - - -
- By connecting, you agree to the {integration.name} terms of service. -
-
-
-
- ); -} \ No newline at end of file diff --git a/apps/webapp/app/components/integrations/api-key-auth-section.tsx b/apps/webapp/app/components/integrations/api-key-auth-section.tsx new file mode 100644 index 0000000..7083b0b --- /dev/null +++ b/apps/webapp/app/components/integrations/api-key-auth-section.tsx @@ -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 ( +
+

API Key Authentication

+ {!showApiKeyForm ? ( + + ) : ( +
+
+ + setApiKey(e.target.value)} + /> + {specData?.auth?.api_key?.description && ( +

+ {specData.auth.api_key.description} +

+ )} +
+
+ + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/integrations/connected-account-section.tsx b/apps/webapp/app/components/integrations/connected-account-section.tsx new file mode 100644 index 0000000..c93d64b --- /dev/null +++ b/apps/webapp/app/components/integrations/connected-account-section.tsx @@ -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 ( +
+

Connected Account

+
+
+

+ Account ID: {activeAccount.id} +

+

+ Connected on{" "} + {new Date(activeAccount.createdAt).toLocaleDateString()} +

+
+ +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/integrations/ingestion-rule-section.tsx b/apps/webapp/app/components/integrations/ingestion-rule-section.tsx new file mode 100644 index 0000000..cc10231 --- /dev/null +++ b/apps/webapp/app/components/integrations/ingestion-rule-section.tsx @@ -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 ( +
+

Ingestion Rule

+
+
+ +