diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0c522537 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + "explorer.compactFolders": false, + "explorer.fileNesting.enabled": false, + "editor.lineHeight": 27, + "editor.folding": true, + "editor.foldingImportsByDefault": true, + "editor.fontLigatures": false, + "gitlens.codeLens.enabled": false, + "gitlens.currentLine.enabled": false, + "typescript.updateImportsOnFileMove.enabled": "always", + "typescript.suggest.autoImports": true, + "javascript.suggest.autoImports": true, + "editor.codeActionsOnSave": { + "source.addMissingImports": "explicit" + }, + "workbench.activityBar.orientation": "vertical", + "workbench.colorCustomizations": { + "activityBar.background": "#393c3e", + "editor.background": "#2b2b2b", + "editorGutter.background": "#2b2b2b", + "editorIndentGuide.background1": "#373737", + "editorIndentGuide.activeBackground1": "#587973", + "editor.lineHighlightBackground": "#323232", + "editor.selectionHighlightBackground": "#40404080", + "editor.selectionBackground": "#214283", + "editor.hoverHighlightBackground": "#40404080", + } +} diff --git a/apps/backend/src/api/routes/copilot.controller.ts b/apps/backend/src/api/routes/copilot.controller.ts index 581343fb..5e23e6fd 100644 --- a/apps/backend/src/api/routes/copilot.controller.ts +++ b/apps/backend/src/api/routes/copilot.controller.ts @@ -2,15 +2,29 @@ import { Logger, Controller, Get, Post, Req, Res, Query } from '@nestjs/common'; import { CopilotRuntime, OpenAIAdapter, - copilotRuntimeNestEndpoint, + copilotRuntimeNodeHttpEndpoint, } from '@copilotkit/runtime'; import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request'; import { Organization } from '@prisma/client'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { MastraAgent } from '@ag-ui/mastra'; +import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service'; +import { Mastra } from '@mastra/core/dist/mastra'; +import { Request, Response } from 'express'; +import { RuntimeContext } from '@mastra/core/di'; +let mastra: Mastra; + +export type ChannelsContext = { + integrations: string; + organization: string; +}; @Controller('/copilot') export class CopilotController { - constructor(private _subscriptionService: SubscriptionService) {} + constructor( + private _subscriptionService: SubscriptionService, + private _mastraService: MastraService + ) {} @Post('/chat') chat(@Req() req: Request, @Res() res: Response) { if ( @@ -21,28 +35,67 @@ export class CopilotController { return; } - const copilotRuntimeHandler = copilotRuntimeNestEndpoint({ + const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({ endpoint: '/copilot/chat', runtime: new CopilotRuntime(), serviceAdapter: new OpenAIAdapter({ - model: - // @ts-ignore - req?.body?.variables?.data?.metadata?.requestType === - 'TextareaCompletion' - ? 'gpt-4o-mini' - : 'gpt-4.1', + model: 'gpt-4.1', + }), + }); + + return copilotRuntimeHandler(req, res); + } + + @Post('/agent') + async agent( + @Req() req: Request, + @Res() res: Response, + @GetOrgFromRequest() organization: Organization + ) { + if ( + process.env.OPENAI_API_KEY === undefined || + process.env.OPENAI_API_KEY === '' + ) { + Logger.warn('OpenAI API key not set, chat functionality will not work'); + return; + } + mastra = mastra || (await this._mastraService.mastra()); + const runtimeContext = new RuntimeContext(); + runtimeContext.set( + 'integrations', + req?.body?.variables?.properties?.integrations || [] + ); + + runtimeContext.set('organization', organization.id); + + const runtime = new CopilotRuntime({ + agents: MastraAgent.getLocalAgents({ + mastra, + // @ts-ignore + runtimeContext, + }), + }); + + const copilotRuntimeHandler = copilotRuntimeNodeHttpEndpoint({ + endpoint: '/copilot/agent', + runtime, + properties: req.body.variables.properties, + serviceAdapter: new OpenAIAdapter({ + model: 'gpt-4.1', }), }); - // @ts-ignore return copilotRuntimeHandler(req, res); } @Get('/credits') calculateCredits( @GetOrgFromRequest() organization: Organization, - @Query('type') type: 'ai_images' | 'ai_videos', + @Query('type') type: 'ai_images' | 'ai_videos' ) { - return this._subscriptionService.checkCredits(organization, type || 'ai_images'); + return this._subscriptionService.checkCredits( + organization, + type || 'ai_images' + ); } } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index c5bbf8ae..b1066fce 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -11,8 +11,9 @@ import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module'; import { McpModule } from '@gitroom/backend/mcp/mcp.module'; import { ThirdPartyModule } from '@gitroom/nestjs-libraries/3rdparties/thirdparty.module'; import { VideoModule } from '@gitroom/nestjs-libraries/videos/video.module'; -import { SentryModule } from "@sentry/nestjs/setup"; +import { SentryModule } from '@sentry/nestjs/setup'; import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; +import { ChatModule } from '@gitroom/nestjs-libraries/chat/chat.module'; @Global() @Module({ @@ -26,6 +27,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; McpModule, ThirdPartyModule, VideoModule, + ChatModule, ThrottlerModule.forRoot([ { ttl: 3600000, @@ -43,7 +45,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; { provide: APP_GUARD, useClass: PoliciesGuard, - } + }, ], exports: [ BullMqModule, @@ -53,6 +55,7 @@ import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception'; AgentModule, McpModule, ThrottlerModule, + ChatModule, ], }) export class AppModule {} diff --git a/apps/backend/src/services/auth/auth.middleware.ts b/apps/backend/src/services/auth/auth.middleware.ts index 0ef8377a..3bb3fea5 100644 --- a/apps/backend/src/services/auth/auth.middleware.ts +++ b/apps/backend/src/services/auth/auth.middleware.ts @@ -6,6 +6,7 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter'; +import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service'; export const removeAuth = (res: Response) => { res.cookie('auth', '', { @@ -20,7 +21,6 @@ export const removeAuth = (res: Response) => { expires: new Date(0), maxAge: -1, }); - res.header('logout', 'true'); }; diff --git a/apps/frontend/src/app/(app)/(site)/agents/page.tsx b/apps/frontend/src/app/(app)/(site)/agents/page.tsx new file mode 100644 index 00000000..ac278b81 --- /dev/null +++ b/apps/frontend/src/app/(app)/(site)/agents/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from 'next'; +import { Agent } from '@gitroom/frontend/components/agents/agent'; +export const metadata: Metadata = { + title: 'Agent', + description: '', +}; +export default async function Page() { + return ( + + ); +} diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index 3b446329..acde904a 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -678,4 +678,25 @@ html[dir='rtl'] [dir='ltr'] { } .blur-xs { filter: blur(4px); +} + +.agent { + .copilotKitInputContainer { + padding: 0 24px !important; + } + + .copilotKitInput { + width: 100% !important; + } +} +.rm-bg .b2 { + padding-top: 0 !important; +} +.rm-bg .b1 { + background: transparent !important; + gap: 0 !important; +} + +.copilotKitMessage img { + width: 200px; } \ No newline at end of file diff --git a/apps/frontend/src/components/agents/agent.input.tsx b/apps/frontend/src/components/agents/agent.input.tsx new file mode 100644 index 00000000..85d22b9b --- /dev/null +++ b/apps/frontend/src/components/agents/agent.input.tsx @@ -0,0 +1,120 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { useCopilotContext, useCopilotReadable } from '@copilotkit/react-core'; +import AutoResizingTextarea from '@gitroom/frontend/components/agents/agent.textarea'; +import { useChatContext } from '@copilotkit/react-ui'; +import { InputProps } from '@copilotkit/react-ui/dist/components/chat/props'; +const MAX_NEWLINES = 6; + +export const Input = ({ + inProgress, + onSend, + isVisible = false, + onStop, + onUpload, + hideStopButton = false, + onChange, +}: InputProps & { onChange: (value: string) => void }) => { + const context = useChatContext(); + const copilotContext = useCopilotContext(); + const showPoweredBy = !copilotContext.copilotApiConfig?.publicApiKey; + + const textareaRef = useRef(null); + const [isComposing, setIsComposing] = useState(false); + + const handleDivClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + + // If the user clicked a button or inside a button, don't focus the textarea + if (target.closest('button')) return; + + // If the user clicked the textarea, do nothing (it's already focused) + if (target.tagName === 'TEXTAREA') return; + + // Otherwise, focus the textarea + textareaRef.current?.focus(); + }; + + const [text, setText] = useState(''); + const send = () => { + if (inProgress) return; + onSend(text); + setText(''); + + textareaRef.current?.focus(); + }; + + const isInProgress = inProgress; + const buttonIcon = + isInProgress && !hideStopButton + ? context.icons.stopIcon + : context.icons.sendIcon; + + const canSend = useMemo(() => { + const interruptEvent = copilotContext.langGraphInterruptAction?.event; + const interruptInProgress = + interruptEvent?.name === 'LangGraphInterruptEvent' && + !interruptEvent?.response; + + return !isInProgress && text.trim().length > 0 && !interruptInProgress; + }, [copilotContext.langGraphInterruptAction?.event, isInProgress, text]); + + const canStop = useMemo(() => { + return isInProgress && !hideStopButton; + }, [isInProgress, hideStopButton]); + + const sendDisabled = !canSend && !canStop; + + return ( +
+
+ { + onChange(event.target.value); + setText(event.target.value); + }} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey && !isComposing) { + event.preventDefault(); + if (canSend) { + send(); + } + } + }} + /> +
+ {onUpload && ( + + )} + +
+ +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/agents/agent.textarea.tsx b/apps/frontend/src/components/agents/agent.textarea.tsx new file mode 100644 index 00000000..ad1f9e1e --- /dev/null +++ b/apps/frontend/src/components/agents/agent.textarea.tsx @@ -0,0 +1,77 @@ +import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react"; + +interface AutoResizingTextareaProps { + maxRows?: number; + placeholder?: string; + value: string; + onChange: (event: React.ChangeEvent) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + onCompositionStart?: () => void; + onCompositionEnd?: () => void; + autoFocus?: boolean; +} + +const AutoResizingTextarea = forwardRef( + ( + { + maxRows = 1, + placeholder, + value, + onChange, + onKeyDown, + onCompositionStart, + onCompositionEnd, + autoFocus, + }, + ref, + ) => { + const internalTextareaRef = useRef(null); + const [maxHeight, setMaxHeight] = useState(0); + + useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement); + + useEffect(() => { + const calculateMaxHeight = () => { + const textarea = internalTextareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + const singleRowHeight = textarea.scrollHeight; + setMaxHeight(singleRowHeight * maxRows); + if (autoFocus) { + textarea.focus(); + } + } + }; + + calculateMaxHeight(); + }, [maxRows]); + + useEffect(() => { + const textarea = internalTextareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; + } + }, [value, maxHeight]); + + return ( +