diff --git a/.agents/features/mcp.md b/.agents/features/mcp.md index dc220cf7c8d..d4933cca478 100644 --- a/.agents/features/mcp.md +++ b/.agents/features/mcp.md @@ -9,7 +9,7 @@ Exposes an Activepieces project as a Model Context Protocol (MCP) server so that - `packages/server/api/src/app/mcp/mcp-entity.ts` — McpServer entity - `packages/server/api/src/app/mcp/tools/index.ts` — static tool exports - `packages/server/api/src/app/mcp/oauth/` — OAuth 2.0 PKCE flow for MCP clients that require OAuth -- `packages/shared/src/lib/automation/mcp/mcp.ts` — McpServer schema, McpToolDefinition type, McpServerStatus enum +- `packages/shared/src/lib/automation/mcp/mcp.ts` — McpServer schema, McpToolDefinition type - `packages/shared/src/lib/automation/mcp/mcp-oauth.ts` — MCP OAuth types - `packages/web/src/app/components/project-settings/mcp-server/index.tsx` — project settings panel for MCP - `packages/web/src/app/components/project-settings/mcp-server/mcp-credentials.tsx` — token display and rotate UI @@ -27,7 +27,7 @@ Exposes an Activepieces project as a Model Context Protocol (MCP) server so that - Cloud: available ## Domain Terms -- **McpServer** — the per-project MCP server record (status, token, enabledTools) +- **McpServer** — the per-project MCP server record (token, enabledTools) - **Locked tools** — tools that are always active when the MCP server is enabled; cannot be disabled - **Controllable tools** — tools that platform or project owners can enable/disable individually - **Dynamic flow tools** — flows that use the MCP trigger piece and are registered as callable tools; tool name format is `{toolName}_{flowId[0..4]}` @@ -37,7 +37,7 @@ Exposes an Activepieces project as a Model Context Protocol (MCP) server so that ## Entity -**McpServer**: id, projectId (UNIQUE — one per project), status (ENABLED/DISABLED), token (72-char auth), enabledTools[] (JSONB, nullable — defaults to ALL_CONTROLLABLE_TOOL_NAMES). +**McpServer**: id, projectId (UNIQUE — one per project), token (72-char auth), enabledTools[] (JSONB, nullable — defaults to ALL_CONTROLLABLE_TOOL_NAMES). ## Tools @@ -72,7 +72,7 @@ Exposes an Activepieces project as a Model Context Protocol (MCP) server so that ## Endpoints - `GET /v1/mcp/:projectId` — get MCP server config + populated flows -- `POST /v1/mcp/:projectId` — update status and/or enabledTools +- `POST /v1/mcp/:projectId` — update enabledTools - `POST /v1/mcp/:projectId/rotate` — rotate auth token - `POST /v1/mcp/:projectId/http` — StreamableHTTP MCP protocol endpoint (main protocol handler) diff --git a/packages/react-ui/public/locales/en/translation.json b/packages/react-ui/public/locales/en/translation.json index 13aa901b147..bbb64eaf016 100644 --- a/packages/react-ui/public/locales/en/translation.json +++ b/packages/react-ui/public/locales/en/translation.json @@ -369,8 +369,6 @@ "Environment": "", "Unsaved changes": "", "Save Changes": "", - "Enable MCP Access": "", - "Allow external agents to read and trigger your project's flows securely.": "", "Tools": "", "Internal Tools": "", "Control which built-in Activepieces tools are available to agents via this MCP server.": "", diff --git a/packages/server/api/src/app/database/migration/postgres/1790000000000-RemoveMcpServerStatus.ts b/packages/server/api/src/app/database/migration/postgres/1790000000000-RemoveMcpServerStatus.ts new file mode 100644 index 00000000000..3039605d070 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1790000000000-RemoveMcpServerStatus.ts @@ -0,0 +1,23 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class RemoveMcpServerStatus1790000000000 implements Migration { + name = 'RemoveMcpServerStatus1790000000000' + breaking = false + release = '0.82.1' + transaction = true + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "mcp_server" + DROP COLUMN "status" + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "mcp_server" + ADD COLUMN "status" varchar NOT NULL DEFAULT 'ENABLED' + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 34291d0fe5b..6f06559e6c3 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -371,6 +371,7 @@ import { MakeChatConversationPlatformWide1787000000000 } from './migration/postg import { AddSsoDomainVerification1787100000000 } from './migration/postgres/1787100000000-AddSsoDomainVerification' import { AddPlatformMcpServer1788000000000 } from './migration/postgres/1788000000000-AddPlatformMcpServer' import { MakeMcpOAuthProjectIdNullable1789000000000 } from './migration/postgres/1789000000000-MakeMcpOAuthProjectIdNullable' +import { RemoveMcpServerStatus1790000000000 } from './migration/postgres/1790000000000-RemoveMcpServerStatus' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -757,6 +758,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddPlatformMcpServer1788000000000, MakeMcpOAuthProjectIdNullable1789000000000, MakeChatConversationPlatformWide1787000000000, + RemoveMcpServerStatus1790000000000, ] return migrations } diff --git a/packages/server/api/src/app/mcp/mcp-entity.ts b/packages/server/api/src/app/mcp/mcp-entity.ts index 345ced063de..fb6e85ae436 100644 --- a/packages/server/api/src/app/mcp/mcp-entity.ts +++ b/packages/server/api/src/app/mcp/mcp-entity.ts @@ -23,10 +23,6 @@ export const McpServerEntity = new EntitySchema({ type: String, nullable: false, }, - status: { - type: String, - nullable: false, - }, token: { type: String, nullable: false, diff --git a/packages/server/api/src/app/mcp/mcp-platform-controller.ts b/packages/server/api/src/app/mcp/mcp-platform-controller.ts index 11d80456340..6c066febb23 100644 --- a/packages/server/api/src/app/mcp/mcp-platform-controller.ts +++ b/packages/server/api/src/app/mcp/mcp-platform-controller.ts @@ -10,10 +10,9 @@ export const mcpPlatformController: FastifyPluginAsyncZod = async (app) => { }) app.post('/', UpdatePlatformMcpRoute, async (req) => { - const { status, enabledTools } = req.body + const { enabledTools } = req.body return mcpServerService(req.log).updatePlatform({ platformId: req.principal.platform.id, - status, enabledTools, }) }) diff --git a/packages/server/api/src/app/mcp/mcp-server-controller.ts b/packages/server/api/src/app/mcp/mcp-server-controller.ts index a60664859cc..da048f415bd 100644 --- a/packages/server/api/src/app/mcp/mcp-server-controller.ts +++ b/packages/server/api/src/app/mcp/mcp-server-controller.ts @@ -12,10 +12,9 @@ export const mcpServerController: FastifyPluginAsyncZod = async (app) => { }) app.post('/', UpdateMcpRequest, async (req) => { - const { status, enabledTools } = req.body + const { enabledTools } = req.body return mcpServerService(req.log).update({ projectId: req.projectId, - status, enabledTools, }) }) diff --git a/packages/server/api/src/app/mcp/mcp-service.ts b/packages/server/api/src/app/mcp/mcp-service.ts index fd6f9b3cd63..fe5fc51897f 100644 --- a/packages/server/api/src/app/mcp/mcp-service.ts +++ b/packages/server/api/src/app/mcp/mcp-service.ts @@ -1,4 +1,4 @@ -import { apId, FlowTriggerType, FlowVersionState, isNil, MCP_TRIGGER_PIECE_NAME, McpServer as McpServerSchema, McpServerStatus, McpServerType, PopulatedFlow, PopulatedMcpServer, spreadIfNotUndefined, tryCatch } from '@activepieces/shared' +import { apId, FlowTriggerType, FlowVersionState, isNil, MCP_TRIGGER_PIECE_NAME, McpServer as McpServerSchema, McpServerType, PopulatedFlow, PopulatedMcpServer, tryCatch } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { repoFactory } from '../core/db/repo-factory' import { flowService } from '../flows/flow/flow.service' @@ -12,14 +12,14 @@ export const mcpServerService = (log: FastifyBaseLogger) => ({ getByProjectId: async (projectId: string): Promise => { return getOrCreate({ where: { projectId }, - defaults: { type: McpServerType.PROJECT, status: McpServerStatus.DISABLED, projectId, platformId: null }, + defaults: { type: McpServerType.PROJECT, projectId, platformId: null }, }) }, getByPlatformId: async (platformId: string): Promise => { return getOrCreate({ where: { platformId }, - defaults: { type: McpServerType.PLATFORM, status: McpServerStatus.ENABLED, platformId, projectId: null }, + defaults: { type: McpServerType.PLATFORM, platformId, projectId: null }, }) }, @@ -46,15 +46,19 @@ export const mcpServerService = (log: FastifyBaseLogger) => ({ return mcpServerService(log).getByPlatformId(platformId) }, - update: async ({ projectId, status, enabledTools }: UpdateParams): Promise => { + update: async ({ projectId, enabledTools }: UpdateParams): Promise => { const mcp = await mcpServerService(log).getByProjectId(projectId) - await applyPatch(mcp.id, { status, enabledTools }) + if (!isNil(enabledTools)) { + await mcpServerRepository().update(mcp.id, { enabledTools }) + } return mcpServerService(log).getPopulatedByProjectId(projectId) }, - updatePlatform: async ({ platformId, status, enabledTools }: UpdatePlatformParams): Promise => { + updatePlatform: async ({ platformId, enabledTools }: UpdatePlatformParams): Promise => { const mcp = await mcpServerService(log).getByPlatformId(platformId) - await applyPatch(mcp.id, { status, enabledTools }) + if (!isNil(enabledTools)) { + await mcpServerRepository().update(mcp.id, { enabledTools }) + } return mcpServerService(log).getByPlatformId(platformId) }, @@ -70,7 +74,7 @@ export const mcpServerService = (log: FastifyBaseLogger) => ({ async function getOrCreate({ where, defaults }: { where: { projectId: string } | { platformId: string } - defaults: { type: McpServerType, status: McpServerStatus, projectId: string | null, platformId: string | null } + defaults: { type: McpServerType, projectId: string | null, platformId: string | null } }): Promise { const existing = await mcpServerRepository().findOneBy(where) if (!isNil(existing)) return existing @@ -91,16 +95,6 @@ async function getOrCreate({ where, defaults }: { return created } -async function applyPatch(id: string, { status, enabledTools }: { status?: McpServerStatus, enabledTools?: string[] }): Promise { - const patch = { - ...spreadIfNotUndefined('status', status), - ...spreadIfNotUndefined('enabledTools', enabledTools), - } - if (Object.keys(patch).length > 0) { - await mcpServerRepository().update(id, patch) - } -} - async function listMcpFlows(projectId: string, logger: FastifyBaseLogger): Promise { const flows = await flowService(logger).list({ projectIds: [projectId], @@ -114,12 +108,10 @@ async function listMcpFlows(projectId: string, logger: FastifyBaseLogger): Promi type UpdateParams = { projectId: string - status?: McpServerStatus enabledTools?: string[] } type UpdatePlatformParams = { platformId: string - status?: McpServerStatus enabledTools?: string[] } diff --git a/packages/server/api/test/integration/ce/mcp/mcp-tools.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-tools.test.ts index 98305074659..a056bf97417 100644 --- a/packages/server/api/test/integration/ce/mcp/mcp-tools.test.ts +++ b/packages/server/api/test/integration/ce/mcp/mcp-tools.test.ts @@ -5,7 +5,6 @@ import { FlowActionType, FlowRunStatus, McpServer, - McpServerStatus, PackageType, PieceType, RunEnvironment, @@ -152,7 +151,6 @@ function makeMcp(projectId: string): McpServer { created: new Date().toISOString(), updated: new Date().toISOString(), projectId, - status: McpServerStatus.ENABLED, token: apId(), enabledTools: null, } diff --git a/packages/server/api/test/integration/cloud/mcp/mcp-piece-visibility.test.ts b/packages/server/api/test/integration/cloud/mcp/mcp-piece-visibility.test.ts index edde30c2cca..2a5f7275100 100644 --- a/packages/server/api/test/integration/cloud/mcp/mcp-piece-visibility.test.ts +++ b/packages/server/api/test/integration/cloud/mcp/mcp-piece-visibility.test.ts @@ -4,7 +4,6 @@ import { apId, FilteredPieceBehavior, McpServer, - McpServerStatus, PackageType, PieceType, } from '@activepieces/shared' @@ -95,7 +94,6 @@ function makeMcp(projectId: string): McpServer { created: new Date().toISOString(), updated: new Date().toISOString(), projectId, - status: McpServerStatus.ENABLED, token: apId(), enabledTools: null, } diff --git a/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts b/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts index ef300abffc5..40638dd443e 100644 --- a/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts +++ b/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts @@ -3,7 +3,6 @@ import { FastifyBaseLogger, FastifyInstance } from 'fastify' import { apId, DefaultProjectRole, - McpServerStatus, McpServerType, Permission, ProjectScopedMcpServer, @@ -35,7 +34,6 @@ function makeMcp(projectId: string): ProjectScopedMcpServer { projectId, platformId: null, type: McpServerType.PROJECT, - status: McpServerStatus.ENABLED, token: apId(), enabledTools: null, } diff --git a/packages/shared/package.json b/packages/shared/package.json index 735b2320da9..b2410234171 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.71.1", + "version": "0.71.2", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/shared/src/lib/automation/mcp/mcp.ts b/packages/shared/src/lib/automation/mcp/mcp.ts index 5b0d09ca21c..d01f6ce2edb 100644 --- a/packages/shared/src/lib/automation/mcp/mcp.ts +++ b/packages/shared/src/lib/automation/mcp/mcp.ts @@ -8,11 +8,6 @@ export type McpId = ApId export const MCP_TRIGGER_PIECE_NAME = '@activepieces/piece-mcp' -export enum McpServerStatus { - ENABLED = 'ENABLED', - DISABLED = 'DISABLED', -} - export enum McpServerType { PLATFORM = 'PLATFORM', PROJECT = 'PROJECT', @@ -23,7 +18,6 @@ export const McpServer = z.object({ platformId: ApId.nullable(), projectId: ApId.nullable(), type: z.enum([McpServerType.PLATFORM, McpServerType.PROJECT]), - status: z.nativeEnum(McpServerStatus), token: ApId, enabledTools: z.array(z.string()).nullable(), }) @@ -38,7 +32,6 @@ export type McpServer = z.infer export type ProjectScopedMcpServer = McpServer & { projectId: string } export const UpdateMcpServerRequest = z.object({ - status: z.nativeEnum(McpServerStatus).optional(), enabledTools: z.array(z.string()).optional(), }) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index d996ccb881f..bdcbf14e682 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -341,8 +341,6 @@ "Environment": "", "Unsaved changes": "", "Save Changes": "", - "Enable MCP Access": "", - "Allow external agents to read and trigger your project's flows securely.": "", "Connection Details": "", "Available Flows": "", "Any flow that has the \"MCP Trigger\" turned on will show up here and can be accessed from your MCP server.": "", @@ -1555,6 +1553,10 @@ "Build this automation": "Build this automation", "Completed all steps": "Completed all steps", "Connect {name}": "Connect {name}", + "Connect another": "Connect another", + "Use this account": "Use this account", + "Using {name}": "Using {name}", + "{count, plural, =1 {1 account already connected} other {# accounts already connected}}": "{count, plural, =1 {1 account already connected} other {# accounts already connected}}", "Go to AI Settings": "Go to AI Settings", "Help me connect two apps": "Help me connect two apps", "I keep doing something manually...": "I keep doing something manually...", @@ -1567,7 +1569,14 @@ "Brainstorm ideas": "Brainstorm ideas", "Show thinking": "Show thinking", "Hide thinking": "Hide thinking", + "Thoughts": "Thoughts", + "{current} of {total}": "{current} of {total}", + "Answers submitted": "Answers submitted", + "Type your answer...": "Type your answer...", + "Skip these questions": "Skip these questions", + "Release to add files to your message": "Release to add files to your message", "Stop": "Stop", + "What can I do for you?": "What can I do for you?", "New chat": "New chat", "New conversation": "New conversation", "Private Chat": "Private Chat", diff --git a/packages/web/src/app/components/project-settings/mcp-server/index.tsx b/packages/web/src/app/components/project-settings/mcp-server/index.tsx index 1bf21ca25d8..a27327781f7 100644 --- a/packages/web/src/app/components/project-settings/mcp-server/index.tsx +++ b/packages/web/src/app/components/project-settings/mcp-server/index.tsx @@ -1,14 +1,6 @@ -import { McpServerStatus } from '@activepieces/shared'; import { t } from 'i18next'; -import { - Field, - FieldContent, - FieldDescription, - FieldLabel, -} from '@/components/custom/field'; import { LoadingSpinner } from '@/components/custom/spinner'; -import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { authenticationSession } from '@/lib/authentication-session'; @@ -33,82 +25,55 @@ export const McpServerSettings = () => { ); } - const isEnabled = mcpServer?.status === McpServerStatus.ENABLED; - - const handleStatusChange = (checked: boolean) => { - updateMcpServer({ - status: checked ? McpServerStatus.ENABLED : McpServerStatus.DISABLED, - }); - }; - return (
- - - {t('Enable MCP Access')} - - {t( - "Allow external agents to read and trigger your project's flows securely.", - )} - - - - - - {isEnabled && mcpServer && ( -
- - - {t('Connection')} - {t('Tools')} - + {mcpServer && ( + + + {t('Connection')} + {t('Tools')} + - - - + + + - -
-

- {t('Internal Tools')} -

-

- {t( - 'Control which built-in Activepieces tools are available to agents via this MCP server.', - )} -

- - updateMcpServer({ enabledTools: tools }) - } - /> -
+ +
+

+ {t('Internal Tools')} +

+

+ {t( + 'Control which built-in Activepieces tools are available to agents via this MCP server.', + )} +

+ + updateMcpServer({ enabledTools: tools }) + } + /> +
-
-

- {t('Your Flows')} -

-

- {t( - 'Flows with the MCP Trigger are exposed as tools on this server.', - )} -

- -
-
-
-
+
+

+ {t('Your Flows')} +

+

+ {t( + 'Flows with the MCP Trigger are exposed as tools on this server.', + )} +

+ +
+ + )}
); diff --git a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx index e156d09d497..ece7f9053ea 100644 --- a/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx +++ b/packages/web/src/app/routes/chat-with-ai/ai-chat-box.tsx @@ -2,7 +2,7 @@ import { AIProviderName } from '@activepieces/shared'; import { t } from 'i18next'; import { AlertTriangle, RefreshCw, Square } from 'lucide-react'; import { motion } from 'motion/react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChatContainerContent, @@ -11,7 +11,6 @@ import { } from '@/components/prompt-kit/chat-container'; import { ScrollButton } from '@/components/prompt-kit/scroll-button'; import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; import { useAgentChat } from '@/features/chat/lib/use-chat'; import { aiProviderQueries } from '@/features/platform-admin'; import { projectCollectionUtils } from '@/features/projects'; @@ -27,6 +26,7 @@ import { ChatMessage } from './components/chat-message'; import { ChatModelSelector } from './components/chat-model-selector'; import { ChatProjectSelector } from './components/chat-project-selector'; import { QuickReplies } from './components/message-content'; +import { MultiQuestionForm } from './components/multi-question-form'; import { getTextFromParts, parseMultiQuestion, @@ -45,15 +45,7 @@ export function AIChatBox({ const chatProvider = providers?.find((p) => p.enabledForChat); const hasChatProvider = Boolean(chatProvider); - if (isLoadingProviders) { - return ( -
- -
- ); - } - - if (!hasChatProvider) { + if (!isLoadingProviders && !hasChatProvider) { return ; } @@ -99,12 +91,9 @@ function ChatBoxContent({ [setProjectContext], ); - const [connectedPieces, setConnectedPieces] = useState>( + const [dismissedFormIds, setDismissedFormIds] = useState>( new Set(), ); - const markPieceConnected = useCallback((piece: string) => { - setConnectedPieces((prev) => new Set(prev).add(piece)); - }, []); useEffect(() => { if (initialConversationId) { @@ -126,12 +115,21 @@ function ChatBoxContent({ }, [messages, sendMessage]); const lastMessage = messages[messages.length - 1]; - const lastMessageText = - lastMessage?.role === 'assistant' - ? getTextFromParts(lastMessage.parts) - : ''; + const lastMessageText = useMemo( + () => + lastMessage?.role === 'assistant' + ? getTextFromParts(lastMessage.parts) + : '', + [lastMessage], + ); + const activeQuestions = useMemo( + () => parseMultiQuestion(lastMessageText).questions, + [lastMessageText], + ); const hasActiveForm = - parseMultiQuestion(lastMessageText).questions.length > 0; + activeQuestions.length > 0 && + !!lastMessage && + !dismissedFormIds.has(lastMessage.id); const isEmpty = messages.length === 0 && !isLoadingHistory && !isStreaming; if (isEmpty) { @@ -179,7 +177,7 @@ function ChatBoxContent({ 'linear-gradient(to bottom, black 0%, black calc(100% - 40px), transparent 100%)', }} > - + {isLoadingHistory && } {messages.map((msg, idx) => { @@ -195,8 +193,6 @@ function ChatBoxContent({ isStreaming={isLastStreamingAssistant} isLastMessage={idx === messages.length - 1} onSend={handleSend} - connectedPieces={connectedPieces} - onPieceConnected={markPieceConnected} onRetry={handleRetry} selectedProjectId={selectedProjectId} projects={projects} @@ -212,18 +208,12 @@ function ChatBoxContent({ )} - {!wasCancelled && - messages.length > 0 && - messages[messages.length - 1]?.role === 'assistant' && ( - - )} + {!wasCancelled && lastMessageText && ( + + )} {error && ( - {!hasActiveForm && ( -
-
+
+
+ {hasActiveForm ? ( + { + if (lastMessage?.id) { + setDismissedFormIds((prev) => { + const next = new Set(prev); + next.add(lastMessage.id); + return next; + }); + } + void handleSend(text); + }} + onDismiss={() => { + if (lastMessage?.id) { + setDismissedFormIds((prev) => { + const next = new Set(prev); + next.add(lastMessage.id); + return next; + }); + } + void handleSend(t('Skip these questions')); + }} + /> + ) : ( } /> -
+ )}
- )} +
); } diff --git a/packages/web/src/app/routes/chat-with-ai/components/chat-input.tsx b/packages/web/src/app/routes/chat-with-ai/components/chat-input.tsx index 36e229e9d40..00387b1712e 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/chat-input.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/chat-input.tsx @@ -90,7 +90,7 @@ export function ChatInput({ placeholder={placeholder ?? t('Tell me what you need...')} className="min-h-[44px] text-sm" /> - +
diff --git a/packages/web/src/app/routes/chat-with-ai/components/chat-message.tsx b/packages/web/src/app/routes/chat-with-ai/components/chat-message.tsx index 1566d21f9e1..ede5340d73d 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/chat-message.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/chat-message.tsx @@ -38,8 +38,6 @@ export function ChatMessage({ isLastMessage = false, onRetry, onSend, - connectedPieces, - onPieceConnected, selectedProjectId, projects, onSelectProject, @@ -49,8 +47,6 @@ export function ChatMessage({ isLastMessage?: boolean; onRetry: () => void; onSend: (text: string, files?: File[]) => void; - connectedPieces: Set; - onPieceConnected: (piece: string) => void; selectedProjectId?: string | null; projects?: Project[]; onSelectProject?: (projectId: string) => void; @@ -66,8 +62,6 @@ export function ChatMessage({ isLastMessage={isLastMessage} onRetry={onRetry} onSend={onSend} - connectedPieces={connectedPieces} - onPieceConnected={onPieceConnected} selectedProjectId={selectedProjectId} projects={projects} onSelectProject={onSelectProject} @@ -75,7 +69,7 @@ export function ChatMessage({ ); } -export function UserMessage({ +function UserMessage({ message, isLastMessage = false, }: { @@ -142,14 +136,12 @@ export function UserMessage({ ); } -export function AssistantMessage({ +function AssistantMessage({ message, isStreaming, isLastMessage = false, onRetry, onSend, - connectedPieces, - onPieceConnected, selectedProjectId, projects, onSelectProject, @@ -159,8 +151,6 @@ export function AssistantMessage({ isLastMessage?: boolean; onRetry: () => void; onSend: (text: string, files?: File[]) => void; - connectedPieces: Set; - onPieceConnected: (piece: string) => void; selectedProjectId?: string | null; projects?: Project[]; onSelectProject?: (projectId: string) => void; @@ -221,10 +211,7 @@ export function AssistantMessage({ {renderParts({ parts: renderableParts, isStreaming, - isLastMessage, onSend, - connectedPieces, - onPieceConnected, selectedProjectId, projects, onSelectProject, @@ -290,20 +277,14 @@ export function AssistantMessage({ function renderParts({ parts, isStreaming, - isLastMessage = false, onSend, - connectedPieces, - onPieceConnected, selectedProjectId, projects, onSelectProject, }: { parts: ChatUIMessage['parts']; isStreaming: boolean; - isLastMessage?: boolean; onSend: (text: string, files?: File[]) => void; - connectedPieces: Set; - onPieceConnected: (piece: string) => void; selectedProjectId?: string | null; projects?: Project[]; onSelectProject?: (projectId: string) => void; @@ -334,9 +315,6 @@ function renderParts({ key={idx} content={part.text} onSend={onSend} - isLastMessage={isLastMessage} - connectedPieces={connectedPieces} - onPieceConnected={onPieceConnected} selectedProjectId={selectedProjectId} projects={projects} onSelectProject={onSelectProject} diff --git a/packages/web/src/app/routes/chat-with-ai/components/chat-thinking-loader.tsx b/packages/web/src/app/routes/chat-with-ai/components/chat-thinking-loader.tsx index 512f3af31a2..7a044d5a620 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/chat-thinking-loader.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/chat-thinking-loader.tsx @@ -15,41 +15,41 @@ function pickRandomIndex(current: number, length: number): number { } const MESSAGES = [ - 'Hold on, thinking real hard 🧠', - 'Hmm let me figure this out 🤔', - 'Cooking something up for you 🍳', - 'Brb, talking to the robots 🤖', - 'Give me a sec, almost there ⏳', - 'No rush... ok maybe a little rush 😅', - 'Connecting all the dots 🔗', - 'My brain is warming up ☕', - 'One moment, doing robot stuff 🦾', - 'Let me ask my robot friends 🤝', - 'Hang tight, magic in progress ✨', - 'Putting the puzzle pieces together 🧩', - 'Almost got it, just one more thing 👀', - 'Thinking... thinking... still thinking 💭', - 'Making your flow extra nice 💅', - 'On it! Be right back 🏃', - 'Grabbing the right tools 🔧', - 'Crunching some numbers real quick 🔢', - 'Let me check my notes 📝', - 'Working behind the scenes 🎬', - 'Doing the heavy lifting for you 💪', - 'Getting everything ready 🎯', - "Okay okay, I'm on it 🫡", - 'Bear with me, good things take a sec 🐻', - "Setting things up, won't be long 🛠️", - 'Let me think about this one 🧐', - 'Running through some ideas 💡', - "Almost ready, just dotting the i's ✍️", - 'Doing my best work here 🎨', - 'Hold tight, something cool is coming 🚀', - 'Working my magic 🪄', - 'Just a few more seconds ⏰', - 'Getting your automation game on 🎮', - 'Figuring out the best way to help 🗺️', - 'Warming up, almost showtime 🎪', + 'Hold on, thinking real hard', + 'Hmm let me figure this out', + 'Cooking something up for you', + 'Brb, talking to the robots', + 'Give me a sec, almost there', + 'No rush... ok maybe a little rush', + 'Connecting all the dots', + 'My brain is warming up', + 'One moment, doing robot stuff', + 'Let me ask my robot friends', + 'Hang tight, magic in progress', + 'Putting the puzzle pieces together', + 'Almost got it, just one more thing', + 'Thinking... thinking... still thinking', + 'Making your flow extra nice', + 'On it! Be right back', + 'Grabbing the right tools', + 'Crunching some numbers real quick', + 'Let me check my notes', + 'Working behind the scenes', + 'Doing the heavy lifting for you', + 'Getting everything ready', + "Okay okay, I'm on it", + 'Bear with me, good things take a sec', + "Setting things up, won't be long", + 'Let me think about this one', + 'Running through some ideas', + "Almost ready, just dotting the i's", + 'Doing my best work here', + 'Hold tight, something cool is coming', + 'Working my magic', + 'Just a few more seconds', + 'Getting your automation game on', + 'Figuring out the best way to help', + 'Warming up, almost showtime', ]; function ChatThinkingLoader({ @@ -86,13 +86,15 @@ function ChatThinkingLoader({ - {t(MESSAGES[messageIndex])} + + {t(MESSAGES[messageIndex])} +
diff --git a/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx b/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx index 4074144572f..fcd0264c5b9 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/message-content.tsx @@ -18,10 +18,9 @@ import { parseAutomationProposal, parseMultiQuestion, parseQuickReplies, + stripIncompleteSpecialBlock, } from '../lib/message-parsers'; -import { MultiQuestionForm } from './multi-question-form'; - const PROSE_CLASSES = 'max-w-none break-words text-sm [&_p]:mb-4 [&_p:last-child]:mb-0 [&_table]:mb-4 [&_h1]:text-[18px] [&_h2]:text-[18px] [&_h3]:text-[18px]'; @@ -43,18 +42,12 @@ function stripAuthContent(content: string): string { export function MessageContentWithAuth({ content, onSend, - isLastMessage = false, - connectedPieces, - onPieceConnected, selectedProjectId, projects, onSelectProject, }: { content: string; onSend?: (text: string) => void; - isLastMessage?: boolean; - connectedPieces?: Set; - onPieceConnected?: (piece: string) => void; selectedProjectId?: string | null; projects?: Project[]; onSelectProject?: (projectId: string) => void; @@ -87,9 +80,9 @@ export function MessageContentWithAuth({ parseAutomationProposal(content); const { connections, cleanContent: afterConnection } = parseAllConnectionsRequired(afterProposal); - const { questions, cleanContent: afterQuestions } = - parseMultiQuestion(afterConnection); - const { cleanContent: finalContent } = parseQuickReplies(afterQuestions); + const { cleanContent: afterQuestions } = parseMultiQuestion(afterConnection); + const { cleanContent: afterReplies } = parseQuickReplies(afterQuestions); + const finalContent = stripIncompleteSpecialBlock(afterReplies); return (
@@ -103,16 +96,8 @@ export function MessageContentWithAuth({ key={conn.piece} connection={conn} onSend={onSend} - connectedPieces={connectedPieces} - onPieceConnected={onPieceConnected} /> ))} - {questions.length > 0 && isLastMessage && ( - onSend?.(text)} - /> - )} {proposal && ( {i + 1} - {step} + {step.label}
))} @@ -232,17 +217,13 @@ export function AutomationProposalCard({ export function ConnectionRequiredCard({ connection, onSend, - connectedPieces, - onPieceConnected, }: { connection: ConnectionRequired; onSend?: (text: string) => void; - connectedPieces?: Set; - onPieceConnected?: (piece: string) => void; }) { const queryClient = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); - const connected = connectedPieces?.has(connection.piece) ?? false; + const [connected, setConnected] = useState(false); const shortName = connection.piece.replace(/[^a-z0-9-]/gi, ''); const pieceName = connection.piece.startsWith('@activepieces/') ? connection.piece @@ -312,7 +293,7 @@ export function ConnectionRequiredCard({ setOpen={(open, createdConnection) => { setDialogOpen(open); if (createdConnection) { - onPieceConnected?.(connection.piece); + setConnected(true); void queryClient.invalidateQueries({ queryKey: ['app-connections'], }); diff --git a/packages/web/src/app/routes/chat-with-ai/components/multi-question-form.tsx b/packages/web/src/app/routes/chat-with-ai/components/multi-question-form.tsx index 955f717d5f6..13bb59c4c66 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/multi-question-form.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/multi-question-form.tsx @@ -1,10 +1,20 @@ import { t } from 'i18next'; -import { ArrowLeft, Check, ChevronRight, Send } from 'lucide-react'; +import { + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + Pencil, + X, +} from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -import { useState } from 'react'; +import { Fragment, useEffect, useId, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; import { MultiQuestion } from '../lib/message-parsers'; @@ -12,36 +22,102 @@ import { MultiQuestion } from '../lib/message-parsers'; export function MultiQuestionForm({ questions, onSubmit, + onDismiss, }: { questions: MultiQuestion[]; onSubmit: (text: string) => void; + onDismiss?: () => void; }) { const [currentStep, setCurrentStep] = useState(0); const [answers, setAnswers] = useState>({}); const [submitted, setSubmitted] = useState(false); + const [focusedRow, setFocusedRow] = useState(null); + const [hoveredRow, setHoveredRow] = useState(null); + const fieldId = useId(); + const firstOptionRef = useRef(null); + const lastOptionRef = useRef(null); + const customAnswerInputRef = useRef(null); + const textInputRef = useRef(null); + const lastFocusedElRef = useRef(null); const isLastStep = currentStep === questions.length - 1; const currentAnswer = answers[currentStep]?.trim() ?? ''; - const allAnswered = questions.every((_q, i) => answers[i]?.trim()); - function handleTextChange(value: string) { + useEffect(() => { + const target = firstOptionRef.current ?? textInputRef.current; + if (target && lastFocusedElRef.current !== target) { + target.focus({ preventScroll: true }); + lastFocusedElRef.current = target; + } + }, [currentStep]); + + function setAnswer(value: string) { setAnswers((prev) => ({ ...prev, [currentStep]: value })); } - function handleNext() { - if (!currentAnswer) return; + function setFirstOptionEl(el: HTMLButtonElement | null) { + firstOptionRef.current = el; + if (el && lastFocusedElRef.current !== el) { + el.focus({ preventScroll: true }); + lastFocusedElRef.current = el; + } + } + + function setTextInputEl(el: HTMLInputElement | null) { + textInputRef.current = el; + if (el && lastFocusedElRef.current !== el) { + el.focus({ preventScroll: true }); + lastFocusedElRef.current = el; + } + } + + function handleNext(overrideValue?: string) { + const value = (overrideValue ?? currentAnswer).trim(); + if (!value) return; + if (overrideValue !== undefined) { + setAnswers((prev) => ({ ...prev, [currentStep]: overrideValue })); + } if (isLastStep) { - handleSubmit(); + if (submitted) return; + const finalAnswers = + overrideValue !== undefined + ? { ...answers, [currentStep]: overrideValue } + : answers; + setSubmitted(true); + const lines = questions + .map((qq, i) => { + const a = finalAnswers[i]?.trim(); + return a ? `- **${qq.question}** ${a}` : null; + }) + .filter((l): l is string => l !== null); + if (lines.length === 0) { + onDismiss?.(); + return; + } + onSubmit(lines.join('\n')); } else { setCurrentStep((s) => s + 1); } } - function handleSubmit() { - if (!allAnswered || submitted) return; - setSubmitted(true); - const lines = questions.map((q, i) => `- **${q.question}** ${answers[i]}`); - onSubmit(lines.join('\n')); + function handleSkip() { + if (isLastStep) { + if (submitted) return; + const lines = questions + .map((qq, i) => { + const a = answers[i]?.trim(); + return a ? `- **${qq.question}** ${a}` : null; + }) + .filter((l): l is string => l !== null); + if (lines.length === 0) { + onDismiss?.(); + return; + } + setSubmitted(true); + onSubmit(lines.join('\n')); + } else { + setCurrentStep((s) => s + 1); + } } if (submitted) { @@ -58,152 +134,341 @@ export function MultiQuestionForm({ } const q = questions[currentStep]; + if (!q) return null; + const isCustomTextActive = + q.type === 'choice' && + !!q.options && + !!answers[currentStep] && + !q.options.includes(answers[currentStep]); + + const choiceOptions = q.type === 'choice' ? q.options ?? [] : []; + const selectedIndex = choiceOptions.findIndex( + (o) => answers[currentStep] === o, + ); + const lastOptionIndex = choiceOptions.length - 1; + const isOptionActive = (i: number) => + focusedRow === i || hoveredRow === i || selectedIndex === i; + const isCustomActive = + focusedRow === 'custom' || hoveredRow === 'custom' || isCustomTextActive; + const isMidSepHidden = (i: number) => + isOptionActive(i) || isOptionActive(i - 1); + const isBottomSepHidden = isOptionActive(lastOptionIndex) || isCustomActive; return ( -
- {questions.map((question, i) => ( - - ))} + + + +
+ +
+ + + {t('{current} of {total}', { + current: currentStep + 1, + total: questions.length, + })} + + + +
-

{q.question}

- - {q.type === 'choice' && q.options && ( -
-
- {q.options.map((option) => ( - - ))} + {isCustomTextActive ? ( + + ) : ( + t('Skip') + )} + +
- - setAnswers((prev) => ({ - ...prev, - [currentStep]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === 'Enter' && currentAnswer) handleNext(); - }} - /> -
- )} - - {q.type === 'text' && ( - handleTextChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleNext(); - }} - /> - )} + )} + + {q.type === 'text' && ( + + )} +
- -
- {currentStep > 0 && ( - - )} - -
); } diff --git a/packages/web/src/app/routes/chat-with-ai/components/proposal-flow-diagram.tsx b/packages/web/src/app/routes/chat-with-ai/components/proposal-flow-diagram.tsx new file mode 100644 index 00000000000..174abf296bc --- /dev/null +++ b/packages/web/src/app/routes/chat-with-ai/components/proposal-flow-diagram.tsx @@ -0,0 +1,92 @@ +import { ChevronDown } from 'lucide-react'; +import { motion, useReducedMotion } from 'motion/react'; +import { Fragment } from 'react'; + +import { PieceIconWithPieceName } from '@/features/pieces/components/piece-icon-from-name'; +import { cn } from '@/lib/utils'; + +import { ProposalStep, stepVisuals } from '../lib/step-visuals'; + +type ProposalFlowDiagramProps = { + steps: ProposalStep[]; +}; + +export function ProposalFlowDiagram({ steps }: ProposalFlowDiagramProps) { + const reduce = useReducedMotion(); + + return ( +
+ {steps.map((step, index) => ( + + {index > 0 && } + + + ))} +
+ ); +} + +function FlowNode({ + step, + index, + reduce, +}: { + step: ProposalStep; + index: number; + reduce: boolean; +}) { + const Icon = stepVisuals.iconFor({ kind: step.kind }); + const tone = stepVisuals.toneFor({ kind: step.kind }); + + return ( + + + {step.piece ? ( + + ) : ( + + )} + + + {step.label} + + + ); +} + +function FlowConnector({ index, reduce }: { index: number; reduce: boolean }) { + return ( + + + + + ); +} diff --git a/packages/web/src/app/routes/chat-with-ai/conversation-list.tsx b/packages/web/src/app/routes/chat-with-ai/conversation-list.tsx index 1e7f0de56fa..f5bc5fa890d 100644 --- a/packages/web/src/app/routes/chat-with-ai/conversation-list.tsx +++ b/packages/web/src/app/routes/chat-with-ai/conversation-list.tsx @@ -111,7 +111,7 @@ export function ConversationList({ if (items.length === 0) return null; const isCollapsed = collapsed[label]; return ( -
+