/** * Composio-aware dropdown fields for the node-config forms. Instead of making * users hand-type a toolkit slug, an action slug, or a trigger slug, these pick * from the user's ACTIVE connections and the live Composio catalog: * * - {@link ComposioToolkitField} — the connected apps the user actually has * (derived from the `FlowConnection[]` the canvas already loaded), so a * `trigger` app_event picks its app from a dropdown. * - {@link ComposioActionField} — the real action slugs for a toolkit * (`composio_list_tools`), for a `tool_call` node's action. * - {@link ComposioTriggerField} — the available trigger slugs for a toolkit * (`composio_list_available_triggers`), for an app_event's trigger. * * The action/trigger lists are fetched on demand (per toolkit) and cached in * local state; they keep the current value selectable even if the catalog fetch * fails, and offer a "Custom…" escape hatch so an advanced/unavailable slug is * never a dead end. All fetches are guarded — outside Tauri (or on error) the * field degrades to the custom input rather than throwing. */ import { useEffect, useMemo, useState } from 'react'; import { listAvailableTriggers, listTools } from '../../../../lib/composio/composioApi'; import { useT } from '../../../../lib/i18n/I18nContext'; import type { FlowConnection } from '../../../../services/api/flowsApi'; import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields'; /** Sentinel select value that reveals a raw text input. */ const CUSTOM = '__custom__'; /** Prettify a toolkit slug for display (`googlesheets` → `Googlesheets`). */ function toolkitLabel(slug: string): string { return slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : slug; } /** Distinct connected Composio toolkits from the canvas's loaded connections. */ export function connectedToolkits(connections: FlowConnection[]): string[] { const seen = new Set(); for (const c of connections) { if (c.kind === 'composio' && c.toolkit) seen.add(c.toolkit); } return [...seen].sort(); } // ── toolkit (app) picker ───────────────────────────────────────────────────── export interface ComposioToolkitFieldProps { label: string; hint?: string; value: string; onChange: (value: string) => void; connections: FlowConnection[]; testId?: string; } export function ComposioToolkitField({ label, hint, value, onChange, connections, testId, }: ComposioToolkitFieldProps) { const { t } = useT(); const toolkits = useMemo(() => connectedToolkits(connections), [connections]); if (toolkits.length === 0) { return (

{t('flows.nodeConfig.composio.noConnections')}

); } // Keep a saved-but-now-disconnected toolkit selectable so editing an existing // flow never silently drops it. const options = toolkits.includes(value) || value === '' ? toolkits : [value, ...toolkits]; return ( ); } // ── shared catalog-slug dropdown (actions + triggers) ──────────────────────── interface CatalogSlugFieldProps { label: string; hint?: string; value: string; onChange: (value: string) => void; /** The toolkit whose catalog to load; empty → prompt to pick a connection. */ toolkit: string; /** Fetch the slugs for a toolkit. */ fetchSlugs: (toolkit: string) => Promise; emptyPrompt: string; testId?: string; } function CatalogSlugField({ label, hint, value, onChange, toolkit, fetchSlugs, emptyPrompt, testId, }: CatalogSlugFieldProps) { const { t } = useT(); const [slugs, setSlugs] = useState([]); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); // Custom mode is entered explicitly, or forced when the catalog can't load so // the field never traps the user with an empty dropdown. const [custom, setCustom] = useState(false); useEffect(() => { if (!toolkit) { setSlugs([]); setFailed(false); return; } let cancelled = false; setLoading(true); setFailed(false); void (async () => { try { const result = await fetchSlugs(toolkit); if (!cancelled) setSlugs(result); } catch { if (!cancelled) setFailed(true); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [toolkit, fetchSlugs]); if (!toolkit) { return (

{emptyPrompt}

); } const showCustomInput = custom || (failed && slugs.length === 0); // Keep the current value selectable even if it's not in the fetched list. const options = value && !slugs.includes(value) ? [value, ...slugs] : slugs; return ( {showCustomInput ? ( onChange(e.target.value)} /> ) : ( )} ); } // ── tool_call action ───────────────────────────────────────────────────────── async function fetchActionSlugs(toolkit: string): Promise { const res = await listTools([toolkit]); return res.tools .map(tool => tool.function?.name) .filter((name): name is string => typeof name === 'string' && name.length > 0); } /** Parsed argument schema of one Composio action (from its JSON-schema `parameters`). */ export interface ComposioActionSchema { /** Argument names the action requires (`parameters.required`). */ required: string[]; /** The remaining declared argument names (`parameters.properties` minus required). */ optional: string[]; /** Raw per-argument JSON-schema fragments (`parameters.properties`). */ properties: Record; } /** Extract a {@link ComposioActionSchema} from a tool's raw `parameters` object. */ function parseActionSchema(parameters: Record | undefined): ComposioActionSchema { const rawProps = parameters?.properties; const properties = rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps) ? (rawProps as Record) : {}; const rawRequired = parameters?.required; const required = Array.isArray(rawRequired) ? rawRequired.filter((name): name is string => typeof name === 'string') : []; const optional = Object.keys(properties).filter(name => !required.includes(name)); return { required, optional, properties }; } /** * Per-toolkit cache of the action → schema map, sharing one in-flight * `composio_list_tools` call per toolkit across all consumers. Failed fetches * are evicted so a transient error doesn't poison the session. */ const actionSchemaCache = new Map>>(); /** * Fetch the argument schema of one Composio action (`toolkit` + action `slug`) * from the live catalog. Returns `null` when the action isn't in the catalog * (e.g. a custom slug); rejects when the catalog fetch itself fails — callers * degrade to the raw args editor in both cases. */ export async function fetchActionSchema( toolkit: string, slug: string ): Promise { let promise = actionSchemaCache.get(toolkit); if (!promise) { promise = listTools([toolkit]).then(res => { const bySlug = new Map(); for (const tool of res.tools) { const name = tool.function?.name; if (typeof name !== 'string' || name.length === 0) continue; bySlug.set(name, parseActionSchema(tool.function.parameters)); } return bySlug; }); actionSchemaCache.set(toolkit, promise); promise.catch(() => actionSchemaCache.delete(toolkit)); } const bySlug = await promise; return bySlug.get(slug) ?? null; } export function ComposioActionField(props: { label: string; hint?: string; value: string; onChange: (value: string) => void; toolkit: string; testId?: string; }) { const { t } = useT(); return ( ); } // ── app_event trigger ──────────────────────────────────────────────────────── async function fetchTriggerSlugs(toolkit: string): Promise { const res = await listAvailableTriggers(toolkit); return res.triggers .map(trigger => trigger.slug) .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0); } export function ComposioTriggerField(props: { label: string; hint?: string; value: string; onChange: (value: string) => void; toolkit: string; testId?: string; }) { const { t } = useT(); return ( ); }