diff --git a/docker-compose.yml b/docker-compose.yml index 494ea07d93c..0b86588af90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: ghcr.io/activepieces/activepieces:0.80.1 + image: ghcr.io/activepieces/activepieces:0.83.0 container_name: activepieces-app restart: unless-stopped ports: diff --git a/docs/mcp/tools.mdx b/docs/mcp/tools.mdx index 9e373b2818c..a9009dac382 100644 --- a/docs/mcp/tools.mdx +++ b/docs/mcp/tools.mdx @@ -57,6 +57,24 @@ Get the detailed input property schema for a specific piece action or trigger. R | `flowId` | string | No | Flow ID for resolving dependent dropdowns that need step context. | | `input` | object | No | Known input values for resolving dependent DYNAMIC properties (e.g., `{"body_type": "json"}`). | +### ap_resolve_property_options + +Resolve dropdown options for a single piece property. Returns available choices with labels and internal IDs. Use this to discover valid values for dynamic dropdown fields (e.g., Slack channels, Google Sheets, email labels) before configuring a step. + +| Input | Type | Required | Description | +|-------|------|----------|-------------| +| `pieceName` | string | Yes | Piece name (e.g., `@activepieces/piece-slack`) | +| `actionOrTriggerName` | string | Yes | Action or trigger name (e.g., `send_channel_message`) | +| `type` | string | Yes | `action` or `trigger` | +| `propertyName` | string | Yes | The exact property name to resolve (e.g., `channel`) | +| `auth` | string | Yes | Connection externalId — required to fetch options from the user's account | +| `input` | object | No | Values for parent properties that this field depends on (refreshers) | +| `searchValue` | string | No | Search term to filter results for large dropdown lists (e.g., "sales" to find sales-related channels) | + + +Each option in the response has a `label` (human-readable name) and a `value` (internal ID). Always pass the **value** when configuring a step — never use the label. For example, if the response includes `{label: "general", value: "C1234567890"}`, use `"C1234567890"` as the channel value. + + ### ap_validate_step_config Validate a step configuration before applying it. Returns field-level errors without modifying any flow. diff --git a/package.json b/package.json index 305140d6c8d..1260cc172c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activepieces", - "version": "0.82.1", + "version": "0.83.0", "packageManager": "bun@1.3.3", "scripts": { "prebuild": "node tools/scripts/install-bun.js", diff --git a/packages/server/api/src/app/ee/platform/admin/admin-platform.service.ts b/packages/server/api/src/app/ee/platform/admin/admin-platform.service.ts index 9637fd8ca43..adb9c48905c 100644 --- a/packages/server/api/src/app/ee/platform/admin/admin-platform.service.ts +++ b/packages/server/api/src/app/ee/platform/admin/admin-platform.service.ts @@ -1,13 +1,9 @@ import { - ActivepiecesError, AdminRetryRunsRequestBody, ApplyLicenseKeyByEmailRequestBody, - ErrorCode, FlowRetryStrategy, FlowRun, - FlowRunStatus, IncreaseAICreditsForPlatformRequestBody, - isNil, PlatformRole, ProjectId, } from '@activepieces/shared' @@ -23,7 +19,6 @@ import { openRouterApi } from '../platform-plan/openrouter/openrouter-api' export const adminPlatformService = (log: FastifyBaseLogger) => ({ - retryRuns: async ({ createdAfter, createdBefore, @@ -32,27 +27,18 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({ const strategy = FlowRetryStrategy.FROM_FAILED_STEP let query = flowRunRepo().createQueryBuilder('flow_run').where({ - status: In([FlowRunStatus.INTERNAL_ERROR]), + id: In(runIds ?? []), }) - if (!isNil(runIds)) { - query = query.andWhere({ - id: In(runIds), + if (!createdBefore) { + query = query.andWhere('flow_run.created <= :createdBefore', { + createdBefore, }) } - if (!createdAfter || !createdBefore) { - throw new ActivepiecesError({ - code: ErrorCode.VALIDATION, - params: { - message: 'createdAfter and createdBefore are required', - }, + if (!createdAfter) { + query = query.andWhere('flow_run.created >= :createdAfter', { + createdAfter, }) } - query = query.andWhere('flow_run.created >= :createdAfter', { - createdAfter, - }) - query = query.andWhere('flow_run.created <= :createdBefore', { - createdBefore, - }) const flowRuns = await query.getMany() const flowRunsByProject = flowRuns.reduce((acc, flowRun) => { @@ -69,6 +55,7 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({ }) } }, + async applyLicenseKeyByEmail({ email, licenseKey }: ApplyLicenseKeyByEmailRequestBody): Promise { const identity = await userIdentityService(log).getIdentityByEmail(email) if (!identity) { @@ -93,7 +80,7 @@ export const adminPlatformService = (log: FastifyBaseLogger) => ({ } await licenseKeysService(log).applyLimits(platform.id, key) }, - async increaseAiCredits({ amountInUsd, platformId }: IncreaseAICreditsForPlatformRequestBody): Promise { + async increaseAiCredits({ amountInUsd, platformId }: IncreaseAICreditsForPlatformRequestBody): Promise { const { apiKeyHash } = await aiProviderService(log).getOrCreateActivePiecesProviderAuthConfig(platformId) const { data: key } = await openRouterApi.getKey({ hash: apiKeyHash }) diff --git a/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts b/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts index 5b1f023ca33..45d571a6a0f 100644 --- a/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts +++ b/packages/server/api/src/app/mcp/tools/ap-get-piece-props.ts @@ -147,7 +147,7 @@ async function resolvePropertyOptions({ props, componentProps, pieceName, pieceV } } catch (err) { - log.debug({ err, propertyName: prop.name }, 'Failed to resolve property, keeping placeholder note') + log.warn({ err, propertyName: prop.name }, 'Failed to resolve property options — dropdown will be empty. Try calling ap_get_piece_props again with auth.') } })) } @@ -183,15 +183,7 @@ async function discoverAvailableConnections({ pieceName, projectId, log }: { } } -function withTimeout({ promise, ms }: { promise: Promise, ms: number }): Promise { - let timer: ReturnType - return Promise.race([ - promise.finally(() => clearTimeout(timer)), - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`Property resolution timed out after ${ms}ms`)), ms) - }), - ]) -} +const { withTimeout } = mcpUtils const getPiecePropsInput = z.object({ pieceName: z.string().describe('The piece name (e.g. "@activepieces/piece-slack"). Use ap_list_pieces to get valid values.'), @@ -202,7 +194,7 @@ const getPiecePropsInput = z.object({ input: z.record(z.string(), z.unknown()).optional().describe('Known input values to resolve dependent dynamic properties.'), }) -const PROPERTY_TIMEOUT_MS = 15_000 +const PROPERTY_TIMEOUT_MS = 30_000 type ResolvePropertyOptionsParams = { props: PropSummary[] diff --git a/packages/server/api/src/app/mcp/tools/ap-resolve-property-options.ts b/packages/server/api/src/app/mcp/tools/ap-resolve-property-options.ts new file mode 100644 index 00000000000..b868d46723b --- /dev/null +++ b/packages/server/api/src/app/mcp/tools/ap-resolve-property-options.ts @@ -0,0 +1,127 @@ +import { PiecePropertyMap, PropertyType } from '@activepieces/pieces-framework' +import { + EngineResponse, + EngineResponseStatus, + isNil, + isObject, + McpToolDefinition, + ProjectScopedMcpServer, + WorkerJobType, +} from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { z } from 'zod' +import { getPiecePackageWithoutArchive } from '../../pieces/metadata/piece-metadata-service' +import { projectService } from '../../project/project-service' +import { userInteractionWatcher } from '../../workers/user-interaction-watcher' +import { mcpUtils } from './mcp-utils' + +const RESOLVE_TIMEOUT_MS = 30_000 +const { withTimeout } = mcpUtils + +export const apResolvePropertyOptionsTool = (mcp: ProjectScopedMcpServer, log: FastifyBaseLogger): McpToolDefinition => { + return { + title: 'ap_resolve_property_options', + description: 'Resolve dropdown options for a single piece property. Returns the available options with labels and values (IDs). Use this to discover valid values for DROPDOWN fields (e.g. Slack channels, Google Sheets, email labels). Always use the `value` from the returned options, not the `label`.', + inputSchema: resolvePropertyOptionsInput.shape, + annotations: { readOnlyHint: true, openWorldHint: false }, + execute: async (args) => { + try { + const { pieceName, actionOrTriggerName, type, propertyName, auth, input: providedInput, searchValue } = resolvePropertyOptionsInput.parse(args) + + const lookup = await mcpUtils.lookupPieceComponent({ + pieceName, + componentName: actionOrTriggerName, + componentType: type, + projectId: mcp.projectId, + log, + }) + if (lookup.error) { + return lookup.error + } + + const { piece, component, pieceName: normalized } = lookup + const propDef = component.props[propertyName] + if (isNil(propDef)) { + return { + content: [{ type: 'text', text: `❌ Property "${propertyName}" not found on ${normalized}/${actionOrTriggerName}. Use ap_get_piece_props to see available properties.` }], + } + } + + const project = await projectService(log).getOneOrThrow(mcp.projectId) + + const piecePackage = await getPiecePackageWithoutArchive(log, project.platformId, { pieceName: normalized, pieceVersion: piece.version }) + + const input: Record = { + ...(providedInput ?? {}), + ...(auth ? { auth: `{{connections['${auth}']}}` } : {}), + } + + const result = await withTimeout({ + promise: userInteractionWatcher.submitAndWaitForResponse | PiecePropertyMap + disabled?: boolean + }>>({ + jobType: WorkerJobType.EXECUTE_PROPERTY, + platformId: project.platformId, + projectId: mcp.projectId, + flowVersion: undefined, + propertyName, + actionOrTriggerName, + input, + sampleData: {}, + searchValue, + piece: piecePackage, + }, log), + ms: RESOLVE_TIMEOUT_MS, + }) + + if (result.status !== EngineResponseStatus.OK || isNil(result.response?.options)) { + return { + content: [{ type: 'text', text: `⚠️ Could not resolve options for "${propertyName}". You may use the value the user provided directly — it may work at runtime. However, the dropdown in the flow editor will appear unset. Mention this to the user.` }], + } + } + + const { options } = result.response + + if (propDef.type === PropertyType.DYNAMIC && isObject(options) && !Array.isArray(options)) { + const dynamicFields = mcpUtils.buildPropSummaries(options as PiecePropertyMap) + return { + content: [{ type: 'text', text: `✅ Dynamic fields for "${propertyName}":\n${JSON.stringify(dynamicFields, null, 2)}` }], + } + } + + if (Array.isArray(options)) { + const mapped = options.map((o: { label: string, value: unknown }) => ({ label: o.label, value: o.value })) + if (mapped.length === 0) { + return { + content: [{ type: 'text', text: `⚠️ No options found for "${propertyName}". The account may have no items. You may use the value the user provided directly, but the dropdown in the flow editor will appear unset.` }], + } + } + return { + content: [{ type: 'text', text: `✅ Options for "${propertyName}" (${mapped.length} found). IMPORTANT: Use the "value" field (the ID), NOT the "label", when setting this property.\n${JSON.stringify(mapped, null, 2)}` }], + } + } + + return { + content: [{ type: 'text', text: `⚠️ Unexpected response format for "${propertyName}".` }], + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + content: [{ type: 'text', text: `⚠️ Options resolution timed out for "${(args as Record).propertyName ?? 'unknown'}": ${message}. You may use the user-provided value directly — it often works at runtime. The dropdown in the flow editor may appear unset; mention this to the user.` }], + } + } + }, + } +} + +const resolvePropertyOptionsInput = z.object({ + pieceName: z.string().describe('The piece name (e.g. "@activepieces/piece-slack").'), + actionOrTriggerName: z.string().describe('The action or trigger name (e.g. "send_channel_message").'), + type: z.enum(['action', 'trigger']).describe('Whether this is an action or trigger.'), + propertyName: z.string().describe('The exact property name to resolve options for (e.g. "channel").'), + auth: z.string().describe('Connection externalId — required to resolve options from the user\'s account.'), + input: z.record(z.string(), z.unknown()).optional().describe('Values for parent properties that this field depends on (refreshers).'), + searchValue: z.string().optional().describe('Search/filter term to narrow results for large dropdown lists (e.g., "sales" to find sales-related channels).'), +}) diff --git a/packages/server/api/src/app/mcp/tools/index.ts b/packages/server/api/src/app/mcp/tools/index.ts index e692c8160a7..26360d077e1 100644 --- a/packages/server/api/src/app/mcp/tools/index.ts +++ b/packages/server/api/src/app/mcp/tools/index.ts @@ -26,6 +26,7 @@ import { apLockAndPublishTool } from './ap-lock-and-publish' import { apManageFieldsTool } from './ap-manage-fields' import { apManageNotesTool } from './ap-manage-notes' import { apRenameFlowTool } from './ap-rename-flow' +import { apResolvePropertyOptionsTool } from './ap-resolve-property-options' import { apRetryRunTool } from './ap-retry-run' import { apRunActionTool } from './ap-run-action' import { apSetupGuideTool } from './ap-setup-guide' @@ -44,6 +45,7 @@ export const LOCKED_TOOL_NAMES: string[] = [ 'ap_validate_flow', 'ap_list_pieces', 'ap_get_piece_props', + 'ap_resolve_property_options', 'ap_validate_step_config', 'ap_list_connections', 'ap_list_ai_models', @@ -94,6 +96,7 @@ export const activepiecesTools = (mcp: ProjectScopedMcpServer, log: FastifyBaseL apValidateFlowTool(mcp, log), apListPiecesTool(mcp, log), apGetPiecePropsTool(mcp, log), + apResolvePropertyOptionsTool(mcp, log), apValidateStepConfigTool(mcp, log), apListConnectionsTool(mcp, log), apUpdateTriggerTool(mcp, log), diff --git a/packages/server/api/src/app/mcp/tools/mcp-utils.ts b/packages/server/api/src/app/mcp/tools/mcp-utils.ts index 569749869ec..c6da87b6979 100644 --- a/packages/server/api/src/app/mcp/tools/mcp-utils.ts +++ b/packages/server/api/src/app/mcp/tools/mcp-utils.ts @@ -113,7 +113,7 @@ function buildPropSummaries(props: PiecePropertyMap, depth = 0): PropSummary[] { summary.options = prop.options.options.map((o: { label: string, value: unknown }) => ({ label: o.label, value: o.value })) } if (prop.type === PropertyType.DROPDOWN || prop.type === PropertyType.MULTI_SELECT_DROPDOWN) { - summary.note = 'Dynamic dropdown — options load from your account via API. Configure in the Activepieces UI, or provide a known value.' + summary.note = 'Resolve with ap_resolve_property_options. Use the returned value (ID), not label.' } if (prop.type === PropertyType.DYNAMIC) { summary.note = 'DYNAMIC — call ap_get_piece_props with auth+input to resolve sub-fields.' @@ -276,6 +276,16 @@ async function fillDefaultsForMissingOptionalProps({ settings, platformId, log } } } +function withTimeout({ promise, ms }: { promise: Promise, ms: number }): Promise { + let timer: ReturnType + return Promise.race([ + promise.finally(() => clearTimeout(timer)), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms) + }), + ]) +} + export const mcpUtils = { mcpToolError, truncate, @@ -289,6 +299,7 @@ export const mcpUtils = { findResolvableProps, validateAuth, fillDefaultsForMissingOptionalProps, + withTimeout, STEP_REFERENCE_HINT, BRANCH_CONDITIONS_INPUT_SCHEMA, } diff --git a/packages/server/api/src/assets/prompts/chat-system-prompt.md b/packages/server/api/src/assets/prompts/chat-system-prompt.md index a05b9e07280..125d6903571 100644 --- a/packages/server/api/src/assets/prompts/chat-system-prompt.md +++ b/packages/server/api/src/assets/prompts/chat-system-prompt.md @@ -18,7 +18,7 @@ A project is always active (shown in the dropdown below the chat input). All too - If the user mentions a different project by name, switch to it with `ap_select_project`. - If the user's request clearly targets a different project than the one selected, ask which one using a multi-question block. -- Never ask "which project?" unprompted — the active project is the right one unless stated otherwise. +- Before building an automation, always confirm the target project using a `project-picker` block (see sequential build process and ui_blocks). When presenting project-scoped results, mention which project you are working in. @@ -27,7 +27,7 @@ When presenting project-scoped results, mention which project you are working in You have access to tools for reading data, building automations, managing tables, and executing actions. Tool risk levels: -- **Read-only** (ap_list_flows, ap_list_connections, ap_find_records, ap_flow_structure, ap_list_runs, ap_get_run): Use freely. No confirmation needed. +- **Read-only** (ap_list_flows, ap_list_connections, ap_find_records, ap_flow_structure, ap_list_runs, ap_get_run, ap_resolve_property_options): Use freely. No confirmation needed. - **Cross-project** (ap_list_across_projects): Lists flows, tables, runs, or connections across ALL projects in one call. Use this when the user asks about resources across projects instead of switching context repeatedly. - **Write** (ap_create_flow, ap_add_step, ap_update_trigger, ap_insert_records, ap_manage_fields): Use after the user approves a proposal or explicitly requests the action. Building without approval wastes the user's time if the result isn't what they wanted. - **Destructive** (ap_delete_step, ap_delete_table, ap_delete_records, ap_change_flow_status): The system will automatically prompt the user for approval before executing. Do NOT add your own confirmation — just call the tool directly when the user asks. @@ -136,68 +136,96 @@ Your **Gmail to Slack Notifications** flow failed at the **Send Slack Message** Follow these steps IN ORDER when the user wants to build an automation. -Step 1 — GATHER REQUIREMENTS -If the request is specific enough (trigger, action, and apps named), skip to Step 2. -Otherwise, ask ONE clarifying question at a time using a multi-question block. Stop and wait. +**Step 1 — GATHER REQUIREMENTS** +If the request names specific apps and actions, skip to Step 2. Otherwise, ask ONE question at a time via a multi-question block. Stop and wait. -Step 2 — CHECK CONNECTIONS -Call ap_list_connections. If a required connection is missing, show ONE connection-required block and wait. -Only proceed after ALL required connections are ready. +**Step 2 — PROPOSE** +Show an `automation-proposal` block. Stop and wait for approval. -Step 3 — PROPOSE -Show the automation-proposal block. Stop and wait for approval. +**Step 3 — CONFIRM PROJECT** +Output a `project-picker` block with 3-5 relevant projects. Always show this — never skip it. Stop and wait. After the user picks, switch with `ap_select_project`. -Step 4 — BUILD & VERIFY -After approval, build using tools (ap_create_flow → ap_update_trigger → ap_add_step). -Output NO text between tool calls — let the progress cards show what is happening. -After building, call ap_validate_flow to verify the flow is valid before telling the user it's ready. -Give a 1-2 sentence summary with a link to the created flow. If validation found issues, fix them or tell the user what needs manual configuration. +**Step 4 — CHECK CONNECTIONS** +Call ap_list_connections. Only show `connection-required` blocks for connections that are MISSING or ERRORED — skip active ones. If all are active, proceed silently. When a connection is created or reconnected via the UI card, it updates silently — no message is sent, do not wait for one. +After the user resolves all connections and clicks Continue, re-call `ap_list_connections` to get the externalIds of the newly created connections before proceeding. -Rules: -- Never combine a question and a proposal in the same message. -- Never combine a connection-required block and a proposal. -- Never build without user approval of the proposal. +**Step 5 — GATHER CONFIGURATION** +This is the most critical step. You must resolve every required field BEFORE building. + +For each step in the proposed automation: +1. Call `ap_get_piece_props` with the pieceName, actionName (or triggerName), AND the `auth` externalId from `ap_list_connections` (call it again if connections were just created in Step 4). This returns the property schema. +2. For each DROPDOWN/MULTI_SELECT_DROPDOWN field, call `ap_resolve_property_options` with the propertyName and auth to get the available options with labels and values. +3. For each resolved dropdown, present the options as a `multi-question` block with `type: choice`. Do NOT use quick-replies for configuration questions — use multi-question blocks so the user gets proper selection UI. +4. For text fields the user hasn't specified, include them in the same multi-question block with `type: text`. Stop and wait. + + +ap_get_piece_props returns properties with these types. Handle each correctly: + +| Type | How to fill | Example | +|------|-------------|---------| +| **STATIC_DROPDOWN** | Options are in `options: [{label, value}]`. Show `label` choices to user. **Use the `value` (ID) in input, NEVER the label.** | Options: `[{label:"testing", value:"C07Q"}]` → user picks "testing" → input: `{channel: "C07Q"}` | +| **DROPDOWN** | Call `ap_resolve_property_options` with pieceName, actionName, propertyName, and auth. If options resolve: show labels in a `multi-question` block with `type: choice`, use the `value` (ID) after user picks. If resolution times out: use the user-provided value directly (works at runtime), but warn the dropdown may appear unset in the editor. | `ap_resolve_property_options` → multi-question with choices → user picks "testing" → use `"C07Q"` | +| **MULTI_SELECT_DROPDOWN** | Same as DROPDOWN but pass an array of `value` IDs. | `{channels: ["C07Q", "C08R"]}` | +| **DYNAMIC** | Call ap_get_piece_props again with current `input` values to resolve `dynamicFields`. Apply these same rules to each sub-field. | Parent: spreadsheet_id resolved first, then sheet_id options load | +| **TEXT / LONG_TEXT** | Ask user if not already provided. Pass as string. | `{message: "Hello!"}` | +| **NUMBER** | Pass as number, not string. | `{limit: 10}` | +| **CHECKBOX** | Pass as boolean. | `{includeArchived: true}` | +| **ARRAY** | Check `items` for sub-property schema. Build each element following these same rules. | `{tags: ["urgent", "sales"]}` | + +**⚠️ Always prefer IDs over names for dropdowns.** +When `ap_resolve_property_options` returns `{label: "General", value: "C1234567890"}`, use `"C1234567890"` — the dropdown will display correctly in the editor. If resolution fails, using the name (e.g., `"general"`) works at runtime but the dropdown will appear unset in the editor. Always try to resolve first. + +**Dependent fields (refreshers):** Some fields depend on others. Resolve parent fields first, then call ap_get_piece_props again with the parent values in `input` to load child options. Example: select a Google Spreadsheet first → then load sheets for that spreadsheet. + + +**Step 6 — BUILD** +Output a `build-progress` block, then call tools silently: ap_create_flow → ap_update_trigger → ap_add_step for each action. Use the exact values from Step 5. + +After each step, call `ap_validate_step_config` to check the configuration: +- ✅ Valid → proceed to next step. +- ⚠️ Invalid → read the error. If it names a missing field, fix it. If you need a value you don't have, ask the user. Retry once, then move on and note what needs manual configuration. + +After all steps, call `ap_validate_flow`. Give a 1-2 sentence summary with a link to the flow. + +When passing `auth` to ap_add_step or ap_update_step, pass the plain connection externalId — the tool wraps it automatically. User: "Send me a Slack message when I get a new Gmail email" -Step 1: Requirements are clear (trigger: Gmail new email, action: Slack send message). Skip. -Step 2: Call ap_list_connections → Gmail ✓, Slack ✓. Both connected. Proceed. -Step 3: Show proposal: -```automation-proposal -title: Gmail to Slack Notifications -description: Get a Slack message every time a new email arrives in Gmail -steps: -- Watch for new emails in Gmail -- Send a notification to your Slack channel -``` -Step 4 (after user approves): Build silently with tools, then summarize. +Step 1: Clear enough. Skip. +Step 2: Show automation-proposal. Wait for approval. +Step 3: Show project-picker. User picks "Team 1". +Step 4: ap_list_connections → Gmail ✓, Slack ✓. Both active. Proceed. +Step 5: ap_get_piece_props for Slack send_channel_message → sees "channel" is DROPDOWN. + ap_resolve_property_options(piece=slack, action=send_channel_message, property=channel, auth=slack_conn_123) + → returns: [{label:"testing", value:"C07Q"}, {label:"general", value:"C08R"}]. + → Show multi-question block: + ```multi-question + title: Slack Channel + question: Which Slack channel should I post to? + type: choice + options: + - testing + - general + ``` + → User picks "testing" → map to value "C07Q" (NOT "testing"). +Step 6: Output build-progress block. Build with channel="C07Q". + ap_validate_step_config after each step. All valid. Done. User: "Automate something for my sales team" -Step 1: Too vague. Ask: -```multi-question -title: Automation Type -question: What kind of automation would help your sales team? -type: choice -options: -- New lead notification -- CRM sync -- Follow-up reminders -- Something else -``` -Wait for response before continuing. +Step 1: Too vague. Ask via multi-question block. Wait. User: "Add a Google Sheets step to my Gmail to Slack flow" This is a modification, not a new flow. Skip the build process. -1. Call ap_list_flows to find the flow. -2. Call ap_flow_structure to see its current steps. -3. Propose the change: "I'll add a Google Sheets row after the Slack step." +1. ap_list_flows → find the flow. +2. ap_flow_structure → see current steps. +3. Propose: "I'll add a Google Sheets row after the Slack step." 4. After approval, call ap_add_step. @@ -225,8 +253,8 @@ Execution rules: How to execute (follow these steps IN ORDER — do not skip any): 1. Call ap_list_across_projects with resource "connections" to find connections across all projects. 2. Show the connection-picker block from the tool output so the user picks which account to use. STOP and wait for their selection. -3. After the user picks, call ap_get_piece_props to get the action schema. Read the **description** field carefully. -4. Fill required fields + enough optional fields to satisfy any business rules in the description. Prefer broad filters over narrow ones. +3. After the user picks, call ap_get_piece_props with the pieceName, actionName, AND the connection's auth externalId. Read the **description** field carefully. +4. Fill required fields following the property_filling_guide in the sequential_build_process section — the same rules apply here. For dropdown fields, use the `value` (ID) from options, never the `label`. Prefer broad filters for read actions. 5. Call ap_run_one_time_action (NOT ap_run_action) with projectId, pieceName, actionName, input, and connectionExternalId. @@ -262,7 +290,7 @@ All flows are healthy. The chat UI renders these fenced code blocks as interactive cards. Use the exact format shown. -Automation proposal (Step 3 only — questions answered, connections ready): +Automation proposal (Step 2 only — after requirements are gathered): ```automation-proposal title: Short Name (3-8 words) description: One sentence explaining the value @@ -272,7 +300,7 @@ steps: - Third action verb step ``` -Suggested next actions (NOT for questions — only for actionable follow-ups): +Suggested next actions — ONLY for suggestions and recommendations, NEVER for gathering information or asking questions. Use multi-question blocks for that. ```quick-replies - Option A - Option B @@ -321,17 +349,47 @@ connections: projectId: proj2 ``` -Missing connection (when no connection exists for the piece): +Missing or broken connections (Step 4 — after project selection, show connections that need attention): ```connection-required -piece: stripe -displayName: Stripe +piece: gmail +displayName: Gmail ``` - +```connection-required +piece: slack +displayName: Slack +status: error +``` +Output one `connection-required` block per connection that needs action. The UI groups them into a single card with a "Continue" button that appears once all are connected. The `status` field is optional. Use `status: error` when the connection exists but needs reconnecting — the UI will show "Reconnect" instead of "Connect". Omit `status` when the connection does not exist at all. + +Project picker (Step 3 — after the user approves a proposal, confirm which project to build in): +```project-picker +suggestedProjects: +- name: Sales Automation + id: proj_abc123 +- name: Marketing Hub + id: proj_def456 +- name: Operations + id: proj_ghi789 +``` +Pick 3-5 projects from the available project list that are most relevant to the automation being built. The UI renders them as clickable chips plus an "Another project" option for the user to search all projects. After the user picks, they will send "Use ." — switch to that project with `ap_select_project` and proceed to build. - -Before requesting a connection, call ap_list_connections. If one exists, use it directly. -When the user connects via the UI, they will send: "Done — X is connected. [auth externalId: abc123]". Use that externalId as the auth value and continue. - +Build progress (Step 6 — output BEFORE calling any build tools): +```build-progress +title: New Lead → Welcome & Notify +project: Personal Project +steps: +- type: trigger + piece: hubspot + label: New Contact Added +- type: action + piece: gmail + label: Send Welcome Email +- type: action + piece: slack + label: Post to #sales +``` +Lists all steps you are about to build. Use short piece names (e.g. `hubspot`, `gmail`, `slack`). The first step must be `type: trigger`, the rest `type: action`. The `label` should be a short description of what the step does (e.g. "New Contact Added", "Send Welcome Email"). The UI renders a live progress card that tracks each step as your tool calls execute — steps transition from Queued → Configuring → Ready. + Always include clickable links when referencing resources: @@ -351,6 +409,7 @@ Patterns that cause mistakes — avoid these: - After building a flow, the flow is in draft state. The user must explicitly ask to publish/enable it — don't auto-publish. - Step references in flow configuration use the format `{{stepName.field}}` — there is no `.output.` in the path. - **Giving up too early**: If a connection or resource is not found in the active project, search all projects before saying "not found." If a tool returns empty, try broader parameters before saying "nothing here." +- **Skipping dropdown resolution**: Always try `ap_resolve_property_options` first to get the internal ID. Using IDs makes the dropdown display correctly in the flow editor. If resolution times out, you can fall back to the user-provided name (it works at runtime), but warn the user the dropdown may appear unset in the editor and they can re-select it there. 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 a056bf97417..c3f8ea27218 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 @@ -578,7 +578,7 @@ describe('MCP Tools integration', () => { expect(text(result)).toContain('✅') expect(text(result)).toContain('folder') expect(text(result)).toContain('DROPDOWN') - expect(text(result)).toContain('Dynamic dropdown') + expect(text(result)).toContain('ap_resolve_property_options') }) it('23. ap_get_piece_props — schema includes all fields regardless of auth', async () => { @@ -611,7 +611,7 @@ describe('MCP Tools integration', () => { expect(text(result)).toContain('✅') expect(text(result)).not.toContain('DROPDOWN') - expect(text(result)).not.toContain('Dynamic dropdown') + expect(text(result)).not.toContain('ap_resolve_property_options') }) it('25. ap_get_piece_props — with auth and invalid piece returns error before resolution', async () => { diff --git a/packages/server/engine/src/lib/variables/processors/file.ts b/packages/server/engine/src/lib/variables/processors/file.ts index 82de11378a9..87e3cc102b9 100644 --- a/packages/server/engine/src/lib/variables/processors/file.ts +++ b/packages/server/engine/src/lib/variables/processors/file.ts @@ -150,5 +150,4 @@ const MIME_EXTENSIONS: Record = { 'font/woff2': 'woff2', 'font/ttf': 'ttf', 'font/otf': 'otf', - 'font/otf': 'otf', } \ No newline at end of file diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index df07903ef56..6cdf4a801d8 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -71,6 +71,15 @@ "Quota Exceeded": "", "Run failed due to exceeding the memory limit of {memoryLimit} MB": "Run failed due to exceeding the memory limit of {memoryLimit} MB", "Queued": "Queued", + "Building": "Building", + "Configuring...": "Configuring...", + "Validating...": "Validating...", + "Ready": "Ready", + "Complete": "Complete", + "TRIGGER": "TRIGGER", + "ACTION {number}": "ACTION {number}", + "Built": "Built", + "Open flow": "Open flow", "Running": "Running", "Run exceeded {timeout} seconds, try to optimize your steps.": "Run exceeded {timeout} seconds, try to optimize your steps.", "Run failed for an unknown reason, contact support.": "Run failed for an unknown reason, contact support.", @@ -205,6 +214,8 @@ "Select an array of items": "Select an array of items", "Select a connection": "Select a connection", "Reconnect": "Reconnect", + "Reconnect {name}": "Reconnect {name}", + "Your {name} connection is expired": "Your {name} connection is expired", "Reconnect & Use": "Reconnect & Use", "Create Connection": "Create Connection", "Incomplete settings": "Incomplete settings", @@ -319,6 +330,9 @@ "Deploy flows across development, staging and production environments with version control and team collaboration": "Deploy flows across development, staging and production environments with version control and team collaboration", "Repository URL": "Repository URL", "Not connected": "Not connected", + "All connections are ready, continue building.": "All connections are ready, continue building.", + "Continue": "Continue", + "All connected": "All connected", "Project Folder": "Project Folder", "Disconnect": "Disconnect", "Enable releases to easily create and manage project releases.": "Enable releases to easily create and manage project releases.", @@ -604,6 +618,10 @@ "Manage your automation projects": "Manage your automation projects", "New Project": "New Project", "No projects found": "No projects found", + "No project found.": "No project found.", + "Another project": "Another project", + "Search projects...": "Search projects...", + "Personal Project": "Personal Project", "Start by creating projects to manage your automation teams": "Start by creating projects to manage your automation teams", "Name is required": "Name is required", "Create New Project": "Create New Project", diff --git a/packages/web/src/app/components/project-settings/mcp-server/utils/mcp-tools-metadata.ts b/packages/web/src/app/components/project-settings/mcp-server/utils/mcp-tools-metadata.ts index 55cb9b43188..e56a4446c6b 100644 --- a/packages/web/src/app/components/project-settings/mcp-server/utils/mcp-tools-metadata.ts +++ b/packages/web/src/app/components/project-settings/mcp-server/utils/mcp-tools-metadata.ts @@ -37,6 +37,11 @@ export const TOOL_CATEGORIES: ToolCategory[] = [ description: 'Get detailed property schema for a specific piece action or trigger', }, + { + name: 'ap_resolve_property_options', + description: + 'Resolve dropdown options for a specific piece property — returns available choices with labels and IDs', + }, { name: 'ap_validate_step_config', description: 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 f807b59c18f..8881aff12b3 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 @@ -155,7 +155,6 @@ function ChatBoxContent({ hasActiveApproval, approvalDisplayName, approve, - approveAndRemember, reject, dismiss: dismissApproval, } = useToolApproval({ pendingApprovalRequest }); @@ -225,7 +224,6 @@ function ChatBoxContent({ onSend={handleSend} onRetry={handleRetry} selectedProjectId={selectedProjectId} - projects={projects} onSelectProject={handleProjectChange} /> ); @@ -278,7 +276,6 @@ function ChatBoxContent({ key={pendingApprovalRequest?.gateId} displayName={approvalDisplayName ?? ''} onApprove={approve} - onApproveAndRemember={approveAndRemember} onReject={reject} onDismiss={dismissApproval} /> diff --git a/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx new file mode 100644 index 00000000000..ccc8bede15d --- /dev/null +++ b/packages/web/src/app/routes/chat-with-ai/components/build-progress-card.tsx @@ -0,0 +1,419 @@ +import { t } from 'i18next'; +import { Check, ChevronDown, ExternalLink, Loader2 } from 'lucide-react'; +import { motion, useReducedMotion } from 'motion/react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { ChatUIMessage, DynamicToolPart } from '@/features/chat/lib/chat-types'; +import { PieceIconWithPieceName } from '@/features/pieces/components/piece-icon-from-name'; +import { cn } from '@/lib/utils'; + +import { BuildProgressData, normalizePieceName } from '../lib/message-parsers'; + +type StepStatus = 'queued' | 'configuring' | 'validating' | 'ready' | 'error'; + +const STEP_ORDER: Record = { + queued: 0, + configuring: 1, + validating: 2, + ready: 3, + error: 3, +}; + +const ANIMATION_DELAY_MS = 350; + +const BUILD_TOOL_NAMES = new Set([ + 'ap_create_flow', + 'ap_build_flow', + 'ap_update_trigger', + 'ap_add_step', + 'ap_update_step', + 'ap_validate_flow', +]); + +function isBuildTool(name: string): boolean { + return BUILD_TOOL_NAMES.has(name); +} + +function computeTargetStatuses({ + steps, + toolParts, +}: { + steps: BuildProgressData['steps']; + toolParts: DynamicToolPart[]; +}): StepStatus[] { + const buildTools = toolParts.filter((t) => isBuildTool(t.toolName)); + if (buildTools.length === 0) return steps.map(() => 'queued'); + + const statuses: StepStatus[] = steps.map(() => 'queued'); + const validateTool = buildTools.find( + (t) => t.toolName === 'ap_validate_flow', + ); + const isValidated = validateTool?.state === 'output-available'; + const isValidating = + validateTool !== undefined && + (validateTool.state === 'input-streaming' || + validateTool.state === 'input-available'); + + if (isValidated) { + statuses.fill('ready'); + return statuses; + } + + for (const tool of buildTools) { + const name = tool.toolName; + const isCompleted = tool.state === 'output-available'; + const isError = tool.state === 'output-error'; + const isRunning = + tool.state === 'input-streaming' || tool.state === 'input-available'; + + if (name === 'ap_validate_flow') continue; + + if (name === 'ap_build_flow') { + if (isCompleted) { + statuses.fill('configuring'); + } else if (isRunning) { + statuses[0] = 'configuring'; + } + continue; + } + + if (isError) { + const firstQueued = statuses.indexOf('queued'); + const idx = firstQueued >= 0 ? firstQueued : statuses.length - 1; + statuses[idx] = 'error'; + continue; + } + + if (isCompleted || isRunning) { + const firstQueued = statuses.indexOf('queued'); + if (firstQueued >= 0) { + statuses[firstQueued] = 'configuring'; + } + } + } + + if (isValidating) { + for (let i = 0; i < statuses.length; i++) { + if (statuses[i] === 'configuring') { + statuses[i] = 'validating'; + } + } + } + + return statuses; +} + +function advanceOneStep({ + current, + target, +}: { + current: StepStatus[]; + target: StepStatus[]; +}): StepStatus[] | null { + for (let i = 0; i < current.length; i++) { + if (current[i] === target[i]) continue; + if (STEP_ORDER[current[i]] >= STEP_ORDER[target[i]]) { + const next = [...current]; + next[i] = target[i]; + return next; + } + + const next = [...current]; + const progression: StepStatus[] = [ + 'queued', + 'configuring', + 'validating', + 'ready', + ]; + const currentIdx = progression.indexOf(current[i]); + const targetIdx = progression.indexOf(target[i]); + if (currentIdx >= 0 && targetIdx > currentIdx) { + next[i] = progression[currentIdx + 1]; + } else { + next[i] = target[i]; + } + return next; + } + return null; +} + +function extractFlowUrl(toolParts: DynamicToolPart[]): string | null { + for (const tool of toolParts) { + if (tool.state !== 'output-available' || !tool.output) continue; + const output = tool.output as Record; + if (typeof output.flowUrl === 'string') return output.flowUrl; + if (typeof output.url === 'string') return output.url; + const content = output.content as Array<{ text?: string }> | undefined; + if (content?.[0]?.text) { + const urlMatch = /https?:\/\/[^\s)]+\/flows\/[^\s)]+/.exec( + content[0].text, + ); + if (urlMatch) return urlMatch[0]; + } + if (typeof output.text === 'string') { + const urlMatch = /https?:\/\/[^\s)]+\/flows\/[^\s)]+/.exec(output.text); + if (urlMatch) return urlMatch[0]; + } + } + return null; +} + +function stepTypeLabel({ + type, + index, +}: { + type: 'trigger' | 'action'; + index: number; +}): string { + if (type === 'trigger') return t('TRIGGER'); + return t('ACTION {number}', { number: index }); +} + +function useAnimatedStatuses({ + targetStatuses, + stepCount, +}: { + targetStatuses: StepStatus[]; + stepCount: number; +}): StepStatus[] { + const [displayed, setDisplayed] = useState(() => + Array.from({ length: stepCount }, () => 'queued' as StepStatus), + ); + + useEffect(() => { + const next = advanceOneStep({ current: displayed, target: targetStatuses }); + if (!next) return; + + const hasAnyProgress = displayed.some( + (s, i) => STEP_ORDER[s] > 0 || STEP_ORDER[targetStatuses[i]] > 0, + ); + const delay = hasAnyProgress ? ANIMATION_DELAY_MS : 0; + + const timer = setTimeout(() => setDisplayed(next), delay); + return () => clearTimeout(timer); + }, [targetStatuses, displayed]); + + return displayed; +} + +export function BuildProgressCard({ + progress, + toolParts, + allParts, + isStreaming = false, +}: BuildProgressCardProps) { + const reduce = useReducedMotion(); + const dynamicParts = useMemo( + () => + toolParts.filter((p): p is DynamicToolPart => p.type === 'dynamic-tool'), + [toolParts], + ); + + const targetStatuses = useMemo( + () => + computeTargetStatuses({ steps: progress.steps, toolParts: dynamicParts }), + [progress.steps, dynamicParts], + ); + + const stepStatuses = useAnimatedStatuses({ + targetStatuses, + stepCount: progress.steps.length, + }); + + const isBuilt = stepStatuses.every((s) => s === 'ready'); + const isValidating = stepStatuses.some((s) => s === 'validating'); + const hasError = stepStatuses.some((s) => s === 'error'); + const flowUrl = useMemo(() => { + const fromTools = extractFlowUrl(dynamicParts); + if (fromTools) return fromTools; + const textParts = allParts ?? toolParts; + const allText = textParts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join(''); + const match = /https?:\/\/[^\s)]+\/flows\/[^\s)]+/.exec(allText); + return match ? match[0] : null; + }, [dynamicParts, allParts, toolParts]); + + const actionIndices = useMemo(() => { + let counter = 0; + return progress.steps.map((step) => + step.type === 'action' ? ++counter : 0, + ); + }, [progress.steps]); + + return ( + +
+
+

+ + {progress.title} +

+ {isBuilt ? ( + + + {t('Built')} + + ) : hasError ? ( + + {t('Error')} + + ) : isValidating ? ( + + + {t('Validating...')} + + ) : ( + + {isStreaming && ( + + )} + {t('Building')} + + )} +
+
+ +
+
+ {progress.steps.map((step, index) => { + const status = stepStatuses[index]; + const typeLabel = + step.type === 'trigger' + ? stepTypeLabel({ type: 'trigger', index: 0 }) + : stepTypeLabel({ + type: 'action', + index: actionIndices[index], + }); + const pieceName = normalizePieceName(step.piece); + + return ( + + {index > 0 && } + +
+ + {typeLabel} + + + {(status === 'configuring' || + status === 'validating') && ( + + )} + {statusLabel(status)} + +
+
+ + + {step.label} + +
+
+
+ ); + })} +
+ + {isBuilt && ( + + {flowUrl && ( + + )} + + )} +
+
+ ); +} + +function statusLabel(status: StepStatus): string { + switch (status) { + case 'ready': + return t('Ready'); + case 'configuring': + return t('Configuring...'); + case 'validating': + return t('Validating...'); + case 'error': + return t('Error'); + case 'queued': + return t('Queued'); + } +} + +function StepConnector({ index, reduce }: { index: number; reduce: boolean }) { + return ( + + + + + ); +} + +type BuildProgressCardProps = { + progress: BuildProgressData; + toolParts: ChatUIMessage['parts']; + allParts?: ChatUIMessage['parts']; + isStreaming?: boolean; +}; 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 73abf4094e5..5fe0dd13a5d 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 @@ -1,4 +1,3 @@ -import { Project } from '@activepieces/shared'; import { t } from 'i18next'; import { Check, Copy, Paperclip, RefreshCw } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; @@ -24,8 +23,9 @@ import { import { ChatUIMessage } from '@/features/chat/lib/chat-types'; import { cn } from '@/lib/utils'; -import { getTextFromParts } from '../lib/message-parsers'; +import { getTextFromParts, parseBuildProgress } from '../lib/message-parsers'; +import { BuildProgressCard } from './build-progress-card'; import { ChatThinkingLoader } from './chat-thinking-loader'; import { MessageContentWithAuth } from './message-content'; import { ToolCallGroup } from './tool-call-group'; @@ -39,7 +39,6 @@ export function ChatMessage({ onRetry, onSend, selectedProjectId, - projects, onSelectProject, }: { message: ChatUIMessage; @@ -48,7 +47,6 @@ export function ChatMessage({ onRetry: () => void; onSend: (text: string, files?: File[]) => void; selectedProjectId?: string | null; - projects?: Project[]; onSelectProject?: (projectId: string) => void; }) { if (message.role === 'user') { @@ -63,7 +61,6 @@ export function ChatMessage({ onRetry={onRetry} onSend={onSend} selectedProjectId={selectedProjectId} - projects={projects} onSelectProject={onSelectProject} /> ); @@ -143,7 +140,6 @@ function AssistantMessage({ onRetry, onSend, selectedProjectId, - projects, onSelectProject, }: { message: ChatUIMessage; @@ -152,7 +148,6 @@ function AssistantMessage({ onRetry: () => void; onSend: (text: string, files?: File[]) => void; selectedProjectId?: string | null; - projects?: Project[]; onSelectProject?: (projectId: string) => void; }) { const reasoningParts = message.parts.filter( @@ -214,7 +209,6 @@ function AssistantMessage({ isLastMessage, onSend, selectedProjectId, - projects, onSelectProject, })} @@ -280,7 +274,6 @@ function renderParts({ isStreaming, onSend, selectedProjectId, - projects, onSelectProject, isLastMessage, }: { @@ -289,9 +282,19 @@ function renderParts({ isLastMessage: boolean; onSend: (text: string, files?: File[]) => void; selectedProjectId?: string | null; - projects?: Project[]; onSelectProject?: (projectId: string) => void; }): React.ReactNode[] { + const fullText = parts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join(''); + const { progress: buildProgress } = parseBuildProgress(fullText); + + const allToolParts = buildProgress + ? parts.filter((p) => p.type === 'dynamic-tool') + : []; + let buildCardRendered = false; + const nodes: React.ReactNode[] = []; const toolBuffer: ChatUIMessage['parts'] = []; @@ -299,13 +302,29 @@ function renderParts({ if (toolBuffer.length === 0) return; const snapshot = [...toolBuffer]; toolBuffer.length = 0; - nodes.push( - , - ); + + if (buildProgress) { + if (!buildCardRendered) { + buildCardRendered = true; + nodes.push( + , + ); + } + } else { + nodes.push( + , + ); + } } parts.forEach((part, idx) => { @@ -319,7 +338,6 @@ function renderParts({ content={part.text} onSend={onSend} selectedProjectId={selectedProjectId} - projects={projects} onSelectProject={onSelectProject} isLastMessage={isLastMessage} />, 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 caa1fa92b55..f8b8df34ffd 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 @@ -1,15 +1,20 @@ -import { PROJECT_COLOR_PALETTE, Project } from '@activepieces/shared'; +import { + AppConnectionStatus, + AppConnectionWithoutSensitiveData, +} from '@activepieces/shared'; import { useQueryClient } from '@tanstack/react-query'; import { t } from 'i18next'; -import { Check, Hammer, Zap } from 'lucide-react'; +import { Check, Zap } from 'lucide-react'; import { motion } from 'motion/react'; -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { CreateOrEditConnectionDialog } from '@/app/connections/create-edit-connection-dialog'; import { Markdown } from '@/components/prompt-kit/markdown'; import { Button } from '@/components/ui/button'; +import { appConnectionsApi } from '@/features/connections/api/app-connections'; import { piecesHooks } from '@/features/pieces'; import { PieceIconWithPieceName } from '@/features/pieces/components/piece-icon-from-name'; +import { authenticationSession } from '@/lib/authentication-session'; import { AutomationProposal, @@ -17,13 +22,16 @@ import { normalizePieceName, parseAllConnectionsRequired, parseAutomationProposal, + parseBuildProgress, parseConnectionPicker, parseMultiQuestion, + parseProjectPicker, parseQuickReplies, stripIncompleteSpecialBlock, } from '../lib/message-parsers'; import { ConnectionPickerCard } from './connection-picker-card'; +import { ProjectPickerCard } from './project-picker-card'; 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]'; @@ -47,14 +55,12 @@ export function MessageContentWithAuth({ content, onSend, selectedProjectId, - projects, onSelectProject, isLastMessage = false, }: { content: string; onSend?: (text: string) => void; selectedProjectId?: string | null; - projects?: Project[]; onSelectProject?: (projectId: string) => void; isLastMessage?: boolean; }) { @@ -88,9 +94,20 @@ export function MessageContentWithAuth({ parseAllConnectionsRequired(afterProposal); const { picker: connectionPicker, cleanContent: afterPicker } = parseConnectionPicker(afterConnection); - const { cleanContent: afterQuestions } = parseMultiQuestion(afterPicker); + const { picker: projectPicker, cleanContent: afterProjectPicker } = + parseProjectPicker(afterPicker); + const { progress: buildProgress, cleanContent: afterBuildProgress } = + parseBuildProgress(afterProjectPicker); + const { cleanContent: afterQuestions } = + parseMultiQuestion(afterBuildProgress); const { cleanContent: afterReplies } = parseQuickReplies(afterQuestions); - const finalContent = stripIncompleteSpecialBlock(afterReplies); + const strippedContent = buildProgress + ? afterReplies + .replace(/\[.*?\]\(https?:\/\/[^\s)]*\/flows\/[^\s)]*\)\s*/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim() + : afterReplies; + const finalContent = stripIncompleteSpecialBlock(strippedContent); return (
@@ -99,13 +116,9 @@ export function MessageContentWithAuth({ {finalContent}
)} - {connections.map((conn) => ( - - ))} + {connections.length > 0 && ( + + )} {connectionPicker && ( )} + {projectPicker && ( + { + onSelectProject?.(projectId); + onSend?.(`Use ${projectName}.`); + }} + /> + )} {proposal && ( onSend?.(`Yes, build the "${proposal.title}" automation`) } - onSelectProjectAndBuild={(projectId) => { - onSelectProject?.(projectId); - onSend?.(`Yes, build the "${proposal.title}" automation`); - }} /> )} @@ -133,20 +151,11 @@ export function MessageContentWithAuth({ export function AutomationProposalCard({ proposal, - selectedProjectId, - projects, onBuild, - onSelectProjectAndBuild, }: { proposal: AutomationProposal; - selectedProjectId: string | null; - projects: Project[]; onBuild: () => void; - onSelectProjectAndBuild: (projectId: string) => void; }) { - const [showProjectPicker, setShowProjectPicker] = useState(false); - const hasProjectContext = selectedProjectId !== null; - return (
@@ -177,70 +186,150 @@ export function AutomationProposalCard({
- {hasProjectContext ? ( - - ) : showProjectPicker ? ( -
-

- {t('Select a project to build in:')} -

-
- {projects.map((project) => { - const color = PROJECT_COLOR_PALETTE[project.icon.color]; - return ( - - ); - })} -
-
- ) : ( - - )} +
); } -export function ConnectionRequiredCard({ +function ConnectionRow({ connection, - onSend, + isConnected, + existingConn, + onConnect, }: { connection: ConnectionRequired; + isConnected: boolean; + existingConn: AppConnectionWithoutSensitiveData | null; + onConnect: () => void; +}) { + const pieceName = normalizePieceName(connection.piece); + const { isLoading } = piecesHooks.usePiece({ name: pieceName }); + const isReconnect = + existingConn !== null && existingConn.status !== AppConnectionStatus.ACTIVE; + + return ( +
+ +
+
{connection.displayName}
+
+ {isConnected + ? t('Ready to use') + : isReconnect + ? t('Your {name} connection is expired', { + name: connection.displayName, + }) + : t('Not connected')} +
+
+ {isConnected ? ( + + + + ) : ( + + )} +
+ ); +} + +function ConnectionsRequiredCard({ + connections, + onSend, +}: { + connections: ConnectionRequired[]; onSend?: (text: string) => void; }) { const queryClient = useQueryClient(); - const [dialogOpen, setDialogOpen] = useState(false); - const [connected, setConnected] = useState(false); - const pieceName = normalizePieceName(connection.piece); - const { pieceModel, isLoading } = piecesHooks.usePiece({ name: pieceName }); + const [connectedSet, setConnectedSet] = useState>(new Set()); + const [existingConns, setExistingConns] = useState< + Record + >({}); + const [activeConnection, setActiveConnection] = + useState(null); + const [continued, setContinued] = useState(false); + + const activePieceName = activeConnection + ? normalizePieceName(activeConnection.piece) + : null; + const { pieceModel } = piecesHooks.usePiece({ + name: activePieceName ?? '', + enabled: !!activePieceName, + }); + + const connectionsKey = useMemo( + () => connections.map((c) => c.piece).join(','), + [connections], + ); + + useEffect(() => { + const projectId = authenticationSession.getProjectId(); + if (!projectId) return; + let cancelled = false; + + void Promise.all( + connections.map(async (conn) => { + const pieceName = normalizePieceName(conn.piece); + const result = await appConnectionsApi.list({ + projectId, + pieceName, + limit: 1, + }); + return { piece: conn.piece, connection: result.data[0] ?? null }; + }), + ).then((results) => { + if (cancelled) return; + const map: Record = {}; + const alreadyActive = new Set(); + for (const { piece, connection } of results) { + if (connection) { + map[piece] = connection; + if (connection.status === AppConnectionStatus.ACTIVE) { + alreadyActive.add(piece); + } + } + } + setExistingConns(map); + if (alreadyActive.size > 0) { + setConnectedSet(alreadyActive); + } + if (alreadyActive.size === connections.length) { + setContinued(true); + } + }); + + return () => { + cancelled = true; + }; + }, [connectionsKey]); + + const allConnected = connections.every((c) => connectedSet.has(c.piece)); + + function handleConnect(connection: ConnectionRequired) { + setActiveConnection(connection); + } return ( <> @@ -255,66 +344,63 @@ export function ConnectionRequiredCard({ damping: 25, }} > -
- ( + handleConnect(conn)} /> -
-

- {connected - ? t('{name} connected', { name: connection.displayName }) - : t('Connect {name}', { name: connection.displayName })} -

-

- {connected - ? t('Ready to use') - : t('This automation needs a {name} connection to work', { - name: connection.displayName, - })} -

+ ))} + + {allConnected && ( +
+ {continued ? ( +
+ + {t('All connected')} +
+ ) : ( + onSend && ( + + ) + )}
- {connected ? ( - - - - ) : ( - - )} -
+ )} - {pieceModel && ( + + {pieceModel && activeConnection && ( { - setDialogOpen(open); - if (createdConnection) { - setConnected(true); - void queryClient.invalidateQueries({ - queryKey: ['app-connections'], - }); - onSend?.( - `Done — ${connection.displayName} is connected. [auth externalId: ${createdConnection.externalId}]`, - ); + if (!open) { + if (createdConnection) { + setConnectedSet((prev) => { + const next = new Set(prev); + next.add(activeConnection.piece); + return next; + }); + void queryClient.invalidateQueries({ + queryKey: ['app-connections'], + }); + } + setActiveConnection(null); } }} - reconnectConnection={null} + reconnectConnection={existingConns[activeConnection.piece] ?? null} isGlobalConnection={false} /> )} diff --git a/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx b/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx new file mode 100644 index 00000000000..b24420a9b7b --- /dev/null +++ b/packages/web/src/app/routes/chat-with-ai/components/project-picker-card.tsx @@ -0,0 +1,179 @@ +import { t } from 'i18next'; +import { Check, Ellipsis } from 'lucide-react'; +import { motion } from 'motion/react'; +import { useState } from 'react'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + ApProjectDisplay, + getProjectName, + projectCollectionUtils, +} from '@/features/projects'; +import { cn } from '@/lib/utils'; + +import { ProjectPickerData } from '../lib/message-parsers'; + +export function ProjectPickerCard({ + picker, + onSelect, + isInteractive = true, + selectedProjectId, +}: ProjectPickerCardProps) { + const { data: allProjects } = projectCollectionUtils.useAll(); + const projects = allProjects ?? []; + const [selected, setSelected] = useState(null); + const [dropdownOpen, setDropdownOpen] = useState(false); + + function handleSelect(projectId: string, name: string) { + setSelected(projectId); + onSelect(projectId, name); + } + + if (selected || !isInteractive) { + const projectId = selected ?? selectedProjectId; + const resolvedProject = projectId + ? projects.find((p) => p.id === projectId) + : null; + const displayName = resolvedProject + ? getProjectName(resolvedProject) + : projectId + ? picker.suggestedProjects.find((p) => p.id === projectId)?.name ?? '' + : picker.suggestedProjects[0]?.name ?? ''; + return ( + +
+ {resolvedProject ? ( + + ) : ( + displayName && {displayName} + )} + +
+
+ ); + } + + return ( + + {picker.suggestedProjects.map((suggested, i) => { + const resolvedProject = projects.find((p) => p.id === suggested.id); + return ( + + handleSelect( + suggested.id, + resolvedProject + ? getProjectName(resolvedProject) + : suggested.name, + ) + } + className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1.5 text-sm hover:bg-muted transition-colors cursor-pointer" + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.2, delay: i * 0.04 }} + > + {resolvedProject ? ( + + ) : ( + {suggested.name} + )} + + ); + })} + + + + + + {t('Another project')} + + + + + + {t('No project found.')} + + {projects.map((project) => ( + { + handleSelect(project.id, getProjectName(project)); + setDropdownOpen(false); + }} + className="cursor-pointer gap-2" + > + + + + ))} + + + + + + ); +} + +type ProjectPickerCardProps = { + picker: ProjectPickerData; + onSelect: (projectId: string, projectName: string) => void; + isInteractive?: boolean; + selectedProjectId?: string | null; +}; diff --git a/packages/web/src/app/routes/chat-with-ai/components/tool-approval-form.tsx b/packages/web/src/app/routes/chat-with-ai/components/tool-approval-form.tsx index 190e7f7703f..adfb442a05c 100644 --- a/packages/web/src/app/routes/chat-with-ai/components/tool-approval-form.tsx +++ b/packages/web/src/app/routes/chat-with-ai/components/tool-approval-form.tsx @@ -11,20 +11,17 @@ import { cn } from '@/lib/utils'; const APPROVAL_OPTIONS = [ { value: 'approve', labelKey: 'Yes, proceed' }, - { value: 'approve-always', labelKey: "Yes, and don't ask me again" }, { value: 'reject', labelKey: 'No, cancel' }, ] as const; export function ToolApprovalForm({ displayName, onApprove, - onApproveAndRemember, onReject, onDismiss, }: { displayName: string; onApprove: () => void; - onApproveAndRemember: () => void; onReject: () => void; onDismiss: () => void; }) { @@ -33,7 +30,6 @@ export function ToolApprovalForm({ function handleSelect(value: string) { if (value === 'approve') onApprove(); - else if (value === 'approve-always') onApproveAndRemember(); else if (value === 'reject') onReject(); } diff --git a/packages/web/src/app/routes/chat-with-ai/index.tsx b/packages/web/src/app/routes/chat-with-ai/index.tsx index 02dcd94e361..639777e4fad 100644 --- a/packages/web/src/app/routes/chat-with-ai/index.tsx +++ b/packages/web/src/app/routes/chat-with-ai/index.tsx @@ -128,18 +128,18 @@ export function ChatWithAIPage() { ]); useEffect(() => { - if (!selectedConversationId) return; + if (!selectedConversationId || conversationTitle) return; let cancelled = false; chatApi .getConversation(selectedConversationId) .then((conv) => { - if (!cancelled) setConversationTitle(conv.title ?? null); + if (!cancelled && conv.title) setConversationTitle(conv.title); }) .catch(() => undefined); return () => { cancelled = true; }; - }, [selectedConversationId]); + }, [selectedConversationId, conversationTitle]); useEffect(() => { const handler = (e: KeyboardEvent) => { diff --git a/packages/web/src/app/routes/chat-with-ai/lib/message-parsers.ts b/packages/web/src/app/routes/chat-with-ai/lib/message-parsers.ts index 546e1360e89..363baf9eb0c 100644 --- a/packages/web/src/app/routes/chat-with-ai/lib/message-parsers.ts +++ b/packages/web/src/app/routes/chat-with-ai/lib/message-parsers.ts @@ -52,6 +52,8 @@ const SPECIAL_FENCES = [ 'automation-proposal', 'connection-required', 'connection-picker', + 'project-picker', + 'build-progress', 'quick-replies', ]; @@ -135,10 +137,12 @@ export function parseAllConnectionsRequired(content: string): { const block = match[1]; const pieceMatch = /^piece:\s*(.+)$/m.exec(block); const nameMatch = /^displayName:\s*(.+)$/m.exec(block); + const statusMatch = /^status:\s*(.+)$/m.exec(block); if (pieceMatch) { connections.push({ piece: pieceMatch[1].trim(), displayName: nameMatch?.[1].trim() ?? pieceMatch[1].trim(), + status: statusMatch?.[1].trim() === 'error' ? 'error' : undefined, }); } cleaned = cleaned.replace(match[0], ''); @@ -209,6 +213,7 @@ export type AutomationProposal = { export type ConnectionRequired = { piece: string; displayName: string; + status?: 'error'; }; export type MultiQuestion = { @@ -315,3 +320,92 @@ export type ConnectionPickerData = { status: AppConnectionStatus; }>; }; + +export function parseProjectPicker(content: string): { + picker: ProjectPickerData | null; + cleanContent: string; +} { + const { block, cleanContent } = parseCodeBlock(content, 'project-picker'); + if (!block) return { picker: null, cleanContent: content }; + + const projects: ProjectPickerData['suggestedProjects'] = []; + const projectBlocks = block.split(/^-\s+name:\s*/m).slice(1); + + for (const projBlock of projectBlocks) { + const lines = projBlock.split('\n'); + const name = lines[0]?.trim(); + if (!name) continue; + + const idMatch = /^\s+id:\s*(.+)$/m.exec(projBlock); + const id = idMatch?.[1].trim() ?? ''; + if (!id) continue; + + projects.push({ name, id }); + } + + if (projects.length === 0) return { picker: null, cleanContent: content }; + + return { + picker: { suggestedProjects: projects }, + cleanContent, + }; +} + +export type ProjectPickerData = { + suggestedProjects: Array<{ + name: string; + id: string; + }>; +}; + +export function parseBuildProgress(content: string): { + progress: BuildProgressData | null; + cleanContent: string; +} { + const { block, cleanContent } = parseCodeBlock(content, 'build-progress'); + if (!block) return { progress: null, cleanContent: content }; + + const titleMatch = /^title:\s*(.+)$/m.exec(block); + const projectMatch = /^project:\s*(.+)$/m.exec(block); + if (!titleMatch) return { progress: null, cleanContent: content }; + + const steps: BuildProgressData['steps'] = []; + const stepBlocks = block.split(/^-\s+type:\s*/m).slice(1); + + for (const stepBlock of stepBlocks) { + const lines = stepBlock.split('\n'); + const type = lines[0]?.trim(); + if (type !== 'trigger' && type !== 'action') continue; + + const pieceMatch = /^\s+piece:\s*(.+)$/m.exec(stepBlock); + const labelMatch = /^\s+label:\s*(.+)$/m.exec(stepBlock); + if (!pieceMatch || !labelMatch) continue; + + steps.push({ + type: type as 'trigger' | 'action', + piece: pieceMatch[1].trim(), + label: labelMatch[1].trim(), + }); + } + + if (steps.length === 0) return { progress: null, cleanContent: content }; + + return { + progress: { + title: titleMatch[1].trim(), + project: projectMatch?.[1].trim() ?? '', + steps, + }, + cleanContent, + }; +} + +export type BuildProgressData = { + title: string; + project: string; + steps: Array<{ + type: 'trigger' | 'action'; + piece: string; + label: string; + }>; +}; diff --git a/packages/web/src/features/chat/lib/use-tool-approval.ts b/packages/web/src/features/chat/lib/use-tool-approval.ts index 6c6e04501bb..b4c7cc3d557 100644 --- a/packages/web/src/features/chat/lib/use-tool-approval.ts +++ b/packages/web/src/features/chat/lib/use-tool-approval.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { API_URL } from '@/lib/api'; import { authenticationSession } from '@/lib/authentication-session'; @@ -32,22 +32,14 @@ export function useToolApproval({ }: { pendingApprovalRequest: ApprovalRequest | null; }) { - const autoApproveRef = useRef(false); const [dismissed, setDismissed] = useState(false); useEffect(() => { if (!pendingApprovalRequest) return; setDismissed(false); - if (autoApproveRef.current) { - void sendApprovalDecision({ - gateId: pendingApprovalRequest.gateId, - approved: true, - }); - } }, [pendingApprovalRequest]); - const hasActiveApproval = - pendingApprovalRequest !== null && !autoApproveRef.current && !dismissed; + const hasActiveApproval = pendingApprovalRequest !== null && !dismissed; const approve = useCallback(() => { if (!pendingApprovalRequest) return; @@ -58,16 +50,6 @@ export function useToolApproval({ }); }, [pendingApprovalRequest]); - const approveAndRemember = useCallback(() => { - if (!pendingApprovalRequest) return; - setDismissed(true); - autoApproveRef.current = true; - void sendApprovalDecision({ - gateId: pendingApprovalRequest.gateId, - approved: true, - }); - }, [pendingApprovalRequest]); - const reject = useCallback(() => { if (!pendingApprovalRequest) return; setDismissed(true); @@ -90,7 +72,6 @@ export function useToolApproval({ hasActiveApproval, approvalDisplayName: pendingApprovalRequest?.displayName ?? null, approve, - approveAndRemember, reject, dismiss, };