mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
503 lines
20 KiB
TypeScript
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} |