Files
openhuman/app/src/components/flows/canvas/nodeConfig/nodeConfigForms.tsx
T

503 lines
20 KiB
TypeScript

/**
* Per-kind config forms for the node-config drawer (issue B5b / Phase 3b).
* Each form renders a small set of typed fields for a high-traffic `NodeKind`,
* reading from the controlled `config` object and merging edits back via
* `onChange` (a shallow-merge patch). Kinds without a dedicated form fall back
* to the drawer's raw-JSON editor.
*
* Field keys mirror what `vendor/tinyflows` actually reads at runtime:
* - `http_request` → `method` / `url` / `connection_ref` / `headers` / `body`
* - `agent` → `prompt` / `model` / `connection_ref`
* - `tool_call` → `slug` / `args` / `connection_ref`
* - `code` → `language` (`javascript`|`python`) / `source`
* - `condition` → `field` (truthiness of a key on the input item)
* - `switch` → `expression` (=-expr, precedence) / `field` (fallback)
* - `transform` → `set` (key → =-expression map)
* - `trigger` → `trigger_kind` + kind-specific (`schedule`, `toolkit`/`trigger_slug`)
* - `memory` → `operation` + operation-specific (`scope`/`query`/`flavour`/`key`/`value`/
* `limit`/`min_score`), see `memoryFields.tsx`
* - `dedup` → `key` (an `=`-bindable per-item id expression), see `dedupFields.tsx`
*/
import createDebug from 'debug';
import { useEffect, useState } from 'react';
import { describeSchedule, scheduleCronExpr } from '../../../../lib/flows/cron';
import type { NodeKind } from '../../../../lib/flows/types';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import AgentNodeInspector from '../AgentNodeInspector';
import {
ComposioActionField,
type ComposioActionSchema,
ComposioToolkitField,
ComposioTriggerField,
fetchActionSchema,
} from './composioFields';
import { DedupForm } from './dedupFields';
import { MemoryForm } from './memoryFields';
import { NATIVE_TOOL_PREFIX, NativeToolField } from './nativeToolFields';
import {
configString,
configStringMap,
CredentialPickerField,
ExpressionField,
JsonField,
KeyMapField,
SelectField,
TextAreaField,
TextField,
UpstreamInsertSelect,
} from './nodeConfigFields';
import { ScheduleField } from './ScheduleField';
import type { UpstreamExpressionOption } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:forms');
/** Derive the toolkit slug from a `composio:<toolkit>:<conn>` connection ref. */
function toolkitFromConnectionRef(ref: string): string {
const parts = ref.split(':');
return parts[0] === 'composio' && parts.length >= 2 ? parts[1] : '';
}
export interface NodeConfigFormProps {
config: Record<string, unknown>;
/** Shallow-merge patch into the node's config (undefined values are still set). */
onChange: (patch: Record<string, unknown>) => void;
connections: FlowConnection[];
/**
* `=nodes.…` expressions referencing this node's upstream ancestors, for the
* insert pickers on expression-bearing fields. Optional — absent (or empty)
* simply hides the pickers.
*/
upstreamOptions?: UpstreamExpressionOption[];
}
type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
// ── trigger ────────────────────────────────────────────────────────────────
const TRIGGER_KINDS = ['manual', 'schedule', 'webhook', 'app_event'] as const;
function TriggerForm({ config, onChange, connections }: NodeConfigFormProps) {
const { t } = useT();
const kind = configString(config, 'trigger_kind') || 'manual';
const toolkit = configString(config, 'toolkit');
return (
<div className="space-y-3">
<SelectField
label={t('flows.nodeConfig.trigger.kindLabel')}
value={kind}
onChange={v => onChange({ trigger_kind: v })}
testId="node-config-trigger-kind"
options={TRIGGER_KINDS.map(k => ({
value: k,
label: t(`flows.nodeConfig.trigger.kind_${k}`),
}))}
/>
{kind === 'schedule' &&
(() => {
const rawSchedule = config.schedule;
const cronExpr = scheduleCronExpr(rawSchedule);
// A cron-shaped schedule (bare string or `{kind:"cron", expr}`) — or
// nothing set yet — is what the visual/advanced builder understands;
// let it render (and start empty, as before, when unset).
if (cronExpr !== null || rawSchedule == null) {
return (
<ScheduleField
value={cronExpr ?? ''}
onChange={v => onChange({ schedule: v })}
testId="node-config-trigger-schedule"
/>
);
}
// `{kind:"every", every_ms}` / `{kind:"at", at}` — the cron builder
// can't model these. Show a read-only summary instead of handing it
// to `ScheduleField`, whose mount effect would otherwise seed (and
// silently overwrite) a default cron string over the real schedule.
return (
<div
className="rounded-lg border border-primary-200 bg-primary-50/60 px-2.5 py-1.5 text-xs font-medium text-primary-700 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300"
data-testid="node-config-trigger-schedule-readonly">
{describeSchedule(rawSchedule)}
</div>
);
})()}
{kind === 'app_event' && (
<>
<ComposioToolkitField
label={t('flows.nodeConfig.trigger.toolkitLabel')}
value={toolkit}
// Changing the app clears a now-mismatched trigger slug.
onChange={v => onChange({ toolkit: v, trigger_slug: '' })}
connections={connections}
testId="node-config-trigger-toolkit"
/>
<ComposioTriggerField
label={t('flows.nodeConfig.trigger.triggerSlugLabel')}
value={configString(config, 'trigger_slug')}
onChange={v => onChange({ trigger_slug: v })}
toolkit={toolkit}
testId="node-config-trigger-slug"
/>
</>
)}
{kind === 'webhook' && (
<p className="rounded-lg border border-dashed border-amber-300 bg-amber-50 px-2.5 py-1.5 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300">
{t('flows.nodeConfig.trigger.webhookHint')}
</p>
)}
</div>
);
}
// ── http_request ─────────────────────────────────────────────────────────────
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
function HttpRequestForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
<SelectField
label={t('flows.nodeConfig.http.methodLabel')}
value={configString(config, 'method') || 'GET'}
onChange={v => onChange({ method: v })}
testId="node-config-http-method"
options={HTTP_METHODS.map(m => ({ value: m, label: m }))}
/>
<ExpressionField
label={t('flows.nodeConfig.http.urlLabel')}
value={configString(config, 'url')}
onChange={v => onChange({ url: v })}
placeholder="https://api.example.com/v1/resource"
upstreamOptions={upstreamOptions}
testId="node-config-http-url"
/>
<CredentialPickerField
value={configString(config, 'connection_ref')}
onChange={v => onChange({ connection_ref: v })}
connections={connections}
kinds={['http']}
testId="node-config-http-credential"
/>
<KeyMapField
label={t('flows.nodeConfig.http.headersLabel')}
value={configStringMap(config, 'headers')}
onChange={v => onChange({ headers: v })}
monoValues
testId="node-config-http-headers"
/>
<JsonField
label={t('flows.nodeConfig.http.bodyLabel')}
value={config.body ?? null}
onChange={v => onChange({ body: v })}
testId="node-config-http-body"
/>
</div>
);
}
// ── agent ────────────────────────────────────────────────────────────────────
function AgentForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
const prompt = configString(config, 'prompt');
return (
<div className="space-y-3">
<TextAreaField
label={t('flows.nodeConfig.agent.promptLabel')}
value={prompt}
onChange={v => onChange({ prompt: v })}
placeholder={t('flows.nodeConfig.agent.promptPlaceholder')}
rows={5}
testId="node-config-agent-prompt"
/>
{upstreamOptions && upstreamOptions.length > 0 && (
// Appends the picked `=nodes.…` expression to the prompt (a prompt is
// prose, so inserting must not clobber what's already written).
<div className="-mt-2 flex justify-end">
<UpstreamInsertSelect
options={upstreamOptions}
onInsert={expr => onChange({ prompt: prompt ? `${prompt} ${expr}` : expr })}
testId="node-config-agent-prompt-upstream"
className="cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1.5 py-1 text-[11px] text-content-muted focus:outline-none"
/>
</div>
)}
{/* Harness knobs: which registered agent runs this node (Phase A routes an
`agent_ref` node through the full tool loop) + its managed model tier.
Both write onto the node config via the same shallow-merge patch. */}
<AgentNodeInspector config={config} onChange={onChange} />
<CredentialPickerField
value={configString(config, 'connection_ref')}
onChange={v => onChange({ connection_ref: v })}
connections={connections}
testId="node-config-agent-credential"
/>
</div>
);
}
// ── tool_call ─────────────────────────────────────────────────────────────────
/** Read `config.args` as a plain object (Composio args are a JSON object). */
function configArgs(config: Record<string, unknown>): Record<string, unknown> {
const args = config.args;
return args && typeof args === 'object' && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
}
function ToolCallForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
const slug = configString(config, 'slug');
// Two flavours of tool_call: a native OpenHuman "Tool" (provider=openhuman /
// slug `oh:...`) vs a Composio "App action". The palette seeds `provider`.
const isNative =
configString(config, 'provider') === 'openhuman' || slug.startsWith(NATIVE_TOOL_PREFIX);
// The connected account is chosen first; its toolkit scopes the action list.
const connectionRef = configString(config, 'connection_ref');
const toolkit = toolkitFromConnectionRef(connectionRef);
// Required-arg preflight rows (Composio only): once an action is selected,
// fetch its JSON-schema `parameters` so each required arg gets its own
// labeled ExpressionField row. `null` (fetch failed / unknown action /
// native tool) degrades gracefully to just the raw JsonField below. The
// fetched schema is tagged with its toolkit/slug key so switching action
// discards a stale schema without a synchronous setState in the effect.
const schemaKey = `${toolkit}${slug}`;
const [fetchedSchema, setFetchedSchema] = useState<{
key: string;
schema: ComposioActionSchema | null;
} | null>(null);
const actionSchema = fetchedSchema?.key === schemaKey ? fetchedSchema.schema : null;
// Remount key for the raw args JsonField: it holds a local text buffer that
// is seeded once, so row edits bump this to re-seed it with the merged args.
const [argsSeed, setArgsSeed] = useState(0);
useEffect(() => {
if (isNative || !toolkit || !slug) return;
const key = `${toolkit}${slug}`;
let cancelled = false;
void (async () => {
try {
const schema = await fetchActionSchema(toolkit, slug);
if (!cancelled) {
log(
'ToolCallForm: schema %s/%s → required=%d optional=%d',
toolkit,
slug,
schema?.required.length ?? -1,
schema?.optional.length ?? -1
);
setFetchedSchema({ key, schema });
}
} catch {
// Catalog fetch failed — keep `null` and fall back to the raw editor.
log('ToolCallForm: schema fetch failed for %s/%s — raw args only', toolkit, slug);
}
})();
return () => {
cancelled = true;
};
}, [isNative, toolkit, slug]);
if (isNative) {
return (
<div className="space-y-3">
<NativeToolField
label={t('flows.nodeConfig.native.toolLabel')}
hint={t('flows.nodeConfig.native.toolHint')}
value={slug}
onChange={v => onChange({ slug: v })}
testId="node-config-tool-slug"
/>
<JsonField
label={t('flows.nodeConfig.tool.argsLabel')}
value={config.args ?? null}
onChange={v => onChange({ args: v })}
testId="node-config-tool-args"
/>
</div>
);
}
const args = configArgs(config);
const requiredArgs = actionSchema?.required ?? [];
const setArg = (name: string, value: string) => {
const next = { ...args };
if (value === '') {
delete next[name];
} else {
next[name] = value;
}
log('ToolCallForm: setArg %s (empty=%s)', name, value === '');
onChange({ args: next });
// Re-seed the raw editor so it reflects the row edit.
setArgsSeed(s => s + 1);
};
return (
<div className="space-y-3">
<CredentialPickerField
value={connectionRef}
// Changing the account can change the toolkit → clear a stale action.
onChange={v => onChange({ connection_ref: v, slug: '' })}
connections={connections}
kinds={['composio']}
testId="node-config-tool-credential"
/>
<ComposioActionField
label={t('flows.nodeConfig.tool.slugLabel')}
value={slug}
onChange={v => onChange({ slug: v })}
toolkit={toolkit}
testId="node-config-tool-slug"
/>
{requiredArgs.map(name => {
const raw = args[name];
const value = typeof raw === 'string' ? raw : raw == null ? '' : JSON.stringify(raw);
const missing = value.trim() === '';
return (
<ExpressionField
key={name}
label={`${name} · ${t('flows.nodeConfig.tool.requiredMark', 'required')}`}
hint=""
value={value}
onChange={v => setArg(name, v)}
upstreamOptions={upstreamOptions}
warning={
missing
? t('flows.nodeConfig.tool.requiredMissing', 'Required — not wired')
: undefined
}
testId={`node-config-tool-arg-${name}`}
/>
);
})}
<JsonField
key={argsSeed}
label={
requiredArgs.length > 0
? t('flows.nodeConfig.tool.argsAdvancedLabel', 'All args (advanced)')
: t('flows.nodeConfig.tool.argsLabel')
}
value={config.args ?? null}
onChange={v => onChange({ args: v })}
testId="node-config-tool-args"
/>
</div>
);
}
// ── condition ─────────────────────────────────────────────────────────────────
function ConditionForm({ config, onChange }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
<TextField
label={t('flows.nodeConfig.condition.fieldLabel')}
hint={t('flows.nodeConfig.condition.fieldHint')}
value={configString(config, 'field')}
onChange={v => onChange({ field: v })}
placeholder="status"
testId="node-config-condition-field"
/>
</div>
);
}
// ── switch ────────────────────────────────────────────────────────────────────
function SwitchForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
<ExpressionField
label={t('flows.nodeConfig.switch.expressionLabel')}
hint={t('flows.nodeConfig.switch.hint')}
value={configString(config, 'expression')}
onChange={v => onChange({ expression: v })}
placeholder="item.type"
upstreamOptions={upstreamOptions}
testId="node-config-switch-expression"
/>
<TextField
label={t('flows.nodeConfig.switch.fieldLabel')}
value={configString(config, 'field')}
onChange={v => onChange({ field: v })}
placeholder="type"
testId="node-config-switch-field"
/>
</div>
);
}
// ── transform ─────────────────────────────────────────────────────────────────
function TransformForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
<KeyMapField
label={t('flows.nodeConfig.transform.setLabel')}
hint={t('flows.nodeConfig.transform.setHint')}
value={configStringMap(config, 'set')}
onChange={v => onChange({ set: v })}
monoValues
upstreamOptions={upstreamOptions}
testId="node-config-transform-set"
/>
</div>
);
}
// ── code ──────────────────────────────────────────────────────────────────────
function CodeForm({ config, onChange }: NodeConfigFormProps) {
const { t } = useT();
const language = configString(config, 'language') || 'javascript';
return (
<div className="space-y-3">
<SelectField
label={t('flows.nodeConfig.code.languageLabel')}
value={language}
onChange={v => onChange({ language: v })}
testId="node-config-code-language"
options={[
{ value: 'javascript', label: t('flows.nodeConfig.code.language_javascript') },
{ value: 'python', label: t('flows.nodeConfig.code.language_python') },
]}
/>
<TextAreaField
label={t('flows.nodeConfig.code.sourceLabel')}
value={configString(config, 'source')}
onChange={v => onChange({ source: v })}
placeholder="return items;"
rows={8}
mono
testId="node-config-code-source"
/>
</div>
);
}
/**
* Registry of the kinds that get a dedicated form. Any `NodeKind` absent here
* (merge, split_out, output_parser, sub_workflow) falls through to the drawer's
* raw-JSON escape hatch.
*/
export const NODE_CONFIG_FORMS: Partial<Record<NodeKind, NodeConfigForm>> = {
trigger: TriggerForm,
http_request: HttpRequestForm,
agent: AgentForm,
tool_call: ToolCallForm,
condition: ConditionForm,
switch: SwitchForm,
transform: TransformForm,
code: CodeForm,
memory: MemoryForm,
dedup: DedupForm,
};