mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Configure workflow tool inputs for Agents (#40285)
This commit is contained in:
parent
05a992d4f2
commit
4cb98aea9f
|
|
@ -659,18 +659,21 @@ describe('formatAgentConfigZodError', () => {
|
|||
});
|
||||
|
||||
describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
||||
it('accepts AI and fixed input bindings on a workflow tool', () => {
|
||||
it('round-trips workflow input bindings', () => {
|
||||
const inputs = {
|
||||
chatId: { mode: 'ai' },
|
||||
query: { mode: 'ai', description: 'Input guidance' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
label: { mode: 'fixed', value: '={{ literal }}' },
|
||||
count: { mode: 'expression', value: '={{ 1 + 2 }}' },
|
||||
};
|
||||
const result = AgentJsonConfigSchema.safeParse({
|
||||
...minimalConfig,
|
||||
tools: [
|
||||
{
|
||||
type: 'workflow',
|
||||
workflow: 'Show Shopping List',
|
||||
inputs: {
|
||||
chatId: { mode: 'ai' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
botName: { mode: 'fixed', value: 'Jarvis' },
|
||||
},
|
||||
inputs,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -679,16 +682,17 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
|||
if (result.success) {
|
||||
expect(result.data.tools?.[0]).toMatchObject({
|
||||
type: 'workflow',
|
||||
inputs: {
|
||||
chatId: { mode: 'ai' },
|
||||
shoppingListId: { mode: 'fixed', value: 'OySx3QNU0BcCs8yz' },
|
||||
botName: { mode: 'fixed', value: 'Jarvis' },
|
||||
},
|
||||
inputs,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a fixed binding without a value', () => {
|
||||
it.each([
|
||||
{ mode: 'fixed' },
|
||||
{ mode: 'expression' },
|
||||
{ mode: 'expression', value: '{{ 1 }}' },
|
||||
{ mode: 'expression', value: '= ' },
|
||||
])('rejects an invalid binding: %j', (binding) => {
|
||||
const result = AgentJsonConfigSchema.safeParse({
|
||||
...minimalConfig,
|
||||
tools: [
|
||||
|
|
@ -696,7 +700,7 @@ describe('WorkflowToolJsonConfigSchema — inputs', () => {
|
|||
type: 'workflow',
|
||||
workflow: 'Show Shopping List',
|
||||
inputs: {
|
||||
botName: { mode: 'fixed' },
|
||||
botName: binding,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -378,11 +378,12 @@ const CustomToolJsonConfigSchema = z.object({
|
|||
|
||||
/**
|
||||
* Per-field binding for a workflow tool's Execute Workflow Trigger inputs.
|
||||
* - `ai`: field is advertised to the LLM and must be supplied at call time.
|
||||
* - `ai`: field is advertised to the LLM with optional input guidance.
|
||||
* - `fixed`: field is omitted from the LLM schema and injected at invoke time.
|
||||
* - `expression`: field is omitted from the LLM schema and resolved at invoke time.
|
||||
*/
|
||||
export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('ai') }).strict(),
|
||||
z.object({ mode: z.literal('ai'), description: z.string().optional() }).strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('fixed'),
|
||||
|
|
@ -390,6 +391,12 @@ export const WorkflowToolInputFieldSchema = z.discriminatedUnion('mode', [
|
|||
value: jsonValueSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('expression'),
|
||||
value: z.string().regex(/^=\s*\S/, 'Enter an expression that starts with ='),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const WorkflowToolJsonConfigSchema = z
|
||||
|
|
|
|||
|
|
@ -68,13 +68,13 @@ describe('createDeepLazyProxy', () => {
|
|||
});
|
||||
|
||||
it('toString() returns "[object Object]"', () => {
|
||||
const p = proxy();
|
||||
const p = proxy(undefined, []);
|
||||
expect(p.toString()).toBe('[object Object]');
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('valueOf() returns the proxy target', () => {
|
||||
const p = proxy();
|
||||
const p = proxy(undefined, []);
|
||||
const val = p.valueOf();
|
||||
expect(typeof val).toBe('object');
|
||||
expect(val).not.toBeNull();
|
||||
|
|
@ -614,20 +614,27 @@ describe('createDeepLazyProxy', () => {
|
|||
expect(Object.keys(p)).toEqual(['name']);
|
||||
});
|
||||
|
||||
it('host data with an own __proto__ key is cached as an own property, not a prototype', () => {
|
||||
const protoValue = { polluted: true };
|
||||
mocks.getValueAtPath.mockImplementation((path: string[]) => {
|
||||
const key = path[path.length - 1];
|
||||
if (key === 'name') return 'Alice';
|
||||
if (key === '__proto__') return protoValue;
|
||||
return undefined;
|
||||
});
|
||||
const p = proxy(['user'], ['name', '__proto__']);
|
||||
p.name = 'Zed'; // triggers materialization over all keys
|
||||
expect(Object.keys(p)).toEqual(['name', '__proto__']);
|
||||
expect(p.polluted).toBeUndefined();
|
||||
expect(p['__proto__']).toBe(protoValue);
|
||||
});
|
||||
it.each(['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf'])(
|
||||
'preserves the own field %s before and after a write',
|
||||
(field) => {
|
||||
const fieldValue = { label: 'Field value' };
|
||||
mocks.getValueAtPath.mockImplementation((path: string[]) => {
|
||||
const key = path[path.length - 1];
|
||||
if (key === 'name') return 'Alice';
|
||||
if (key === field) return fieldValue;
|
||||
return undefined;
|
||||
});
|
||||
const p = proxy(['user'], ['name', field]);
|
||||
expect(Object.fromEntries(Object.entries(p))).toEqual({
|
||||
name: 'Alice',
|
||||
[field]: fieldValue,
|
||||
});
|
||||
p.name = 'Zed'; // triggers materialization over all keys
|
||||
expect(Object.keys(p)).toEqual(['name', field]);
|
||||
expect(p.label).toBeUndefined();
|
||||
expect(p[field]).toBe(fieldValue);
|
||||
},
|
||||
);
|
||||
|
||||
it('write fetches each uncached key exactly once; later access stays local', () => {
|
||||
const p = writableObjectProxy();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isKeyOf } from './utils';
|
||||
|
||||
// ============================================================================
|
||||
// Deep Lazy Proxy System
|
||||
// ============================================================================
|
||||
|
|
@ -312,7 +314,10 @@ export function createDeepLazyProxy(
|
|||
return arrayLength;
|
||||
}
|
||||
|
||||
if (prop in targetObj) {
|
||||
if (
|
||||
isKeyOf(targetObj, prop) ||
|
||||
(prop in targetObj && (isArray || !resolveObjectKeys().includes(prop)))
|
||||
) {
|
||||
return targetObj[prop];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1231,6 +1231,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'version-456',
|
||||
versionId: 'draft-version',
|
||||
nodes: currentNodes,
|
||||
connections: currentConnections,
|
||||
activeVersion: mock({
|
||||
|
|
@ -1249,6 +1250,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
|
||||
expect(result.nodes).toEqual(activeVersionNodes);
|
||||
expect(result.connections).toEqual(activeVersionConnections);
|
||||
expect(result.versionId).toBe('version-456');
|
||||
expect(workflowRepository.get).toHaveBeenCalledWith(
|
||||
{ id: 'workflow-123' },
|
||||
{ relations: ['activeVersion', 'tags'] },
|
||||
|
|
@ -1381,8 +1383,13 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
];
|
||||
const mappingConnections = { 'Mapping Node': {} };
|
||||
workflowPublishedDataService.getPublishedWorkflowData.mockResolvedValue({
|
||||
workflow: mock<WorkflowEntity>({ id: 'workflow-123', name: 'Test Workflow' }),
|
||||
workflow: mock<WorkflowEntity>({
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
versionId: 'draft-version',
|
||||
}),
|
||||
publishedVersion: mock<WorkflowHistory>({
|
||||
versionId: 'published-version',
|
||||
nodes: mappingNodes,
|
||||
connections: mappingConnections,
|
||||
}),
|
||||
|
|
@ -1397,6 +1404,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
expect(workflowRepository.get).not.toHaveBeenCalled();
|
||||
expect(result.nodes).toEqual(mappingNodes);
|
||||
expect(result.connections).toEqual(mappingConnections);
|
||||
expect(result.versionId).toBe('published-version');
|
||||
});
|
||||
|
||||
it('throws when there is no published version (flag on)', async () => {
|
||||
|
|
|
|||
|
|
@ -682,7 +682,17 @@ describe('AgentPublishService', () => {
|
|||
versionId: 'published-v1',
|
||||
schema: {
|
||||
...schema,
|
||||
tools: [{ type: 'custom', id: 'tool', enabled: false, requireApproval: true }],
|
||||
tools: [
|
||||
{ type: 'custom', id: 'tool', enabled: false, requireApproval: true },
|
||||
{
|
||||
type: 'workflow',
|
||||
workflow: 'Test workflow',
|
||||
inputs: {
|
||||
constructor: { mode: 'ai' as const, description: 'Use the label.' },
|
||||
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
|
||||
},
|
||||
},
|
||||
],
|
||||
skills: [{ type: 'skill', id: 'skill', enabled: false }],
|
||||
subAgents: { agents: [{ agentId: 'agent-2', enabled: false, useWhen: 'Review notes' }] },
|
||||
tasks: [{ type: 'task', id: 'task-1', enabled: false }],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
|
||||
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
import {
|
||||
|
|
@ -85,6 +86,7 @@ function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext
|
|||
|
||||
return {
|
||||
workflowLoader,
|
||||
executor: mock<EphemeralNodeExecutor>(),
|
||||
workflowRunner,
|
||||
subworkflowPolicyChecker: mock<SubworkflowPolicyChecker>(),
|
||||
activeExecutions,
|
||||
|
|
@ -316,6 +318,85 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
|
|||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves configured inputs for new calls and preserves them on continuation', async () => {
|
||||
const trigger = makeExecuteWorkflowTriggerNode({
|
||||
parameters: {
|
||||
inputSource: 'jsonExample',
|
||||
jsonExample: JSON.stringify({ sides: 6, count: 1, label: '' }),
|
||||
},
|
||||
});
|
||||
const context = makeContext(makeWorkflow({}, trigger));
|
||||
const executor = mock<EphemeralNodeExecutor>();
|
||||
executor.evaluateExpressions
|
||||
.mockResolvedValueOnce({ count: 2 })
|
||||
.mockResolvedValueOnce({ count: 3 })
|
||||
.mockResolvedValueOnce({ count: 'invalid' })
|
||||
.mockRejectedValueOnce(new Error('Cannot resolve input "count"'));
|
||||
context.executor = executor;
|
||||
const run = vi.fn().mockResolvedValue('exec-1');
|
||||
context.workflowRunner.run = run;
|
||||
context.activeExecutions.has = vi.fn().mockReturnValue(false);
|
||||
Container.set(ExecutionPersistence, {
|
||||
findSingleExecution: vi.fn().mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { resultData: { runData: {} } },
|
||||
}),
|
||||
} as unknown as ExecutionPersistence);
|
||||
|
||||
const tool = await resolveWorkflowTool(
|
||||
{
|
||||
type: 'workflow',
|
||||
workflow: 'My Test Workflow',
|
||||
inputs: {
|
||||
count: { mode: 'expression', value: '={{ $json.sides / 3 }}' },
|
||||
hasOwnProperty: { mode: 'expression' as const, value: '={{ $json.removed }}' },
|
||||
label: { mode: 'fixed', value: '={{ literal }}' },
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
for (const [sides, count] of [
|
||||
[6, 2],
|
||||
[9, 3],
|
||||
]) {
|
||||
await tool.handler?.({ sides, count: 99, label: 'model value' }, {});
|
||||
expect(run).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
pinData: { [TRIGGER_NAME]: [{ json: { sides, count, label: '={{ literal }}' } }] },
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
}
|
||||
await expect(tool.handler?.({ sides: 6 }, {})).rejects.toThrow('count');
|
||||
await expect(tool.handler?.({ sides: 6 }, {})).rejects.toThrow('Cannot resolve input "count"');
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(executor.evaluateExpressions).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ projectId: 'project-1' }),
|
||||
{ count: '={{ $json.sides / 3 }}' },
|
||||
[{ json: { sides: 6 } }],
|
||||
);
|
||||
|
||||
await expect(
|
||||
tool.handler?.(
|
||||
{},
|
||||
{
|
||||
continuation: { executionId: 'exec-1' },
|
||||
suspend: vi.fn(),
|
||||
resumeData: undefined,
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
expect(executor.evaluateExpressions).toHaveBeenCalledTimes(4);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow tool compatibility', () => {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export class AgentDefinitionService {
|
|||
ctx: OperationContext = {},
|
||||
): Promise<boolean> {
|
||||
return await this.transactionRunner.run(ctx, async (txCtx) => {
|
||||
agent.schema = deepCopy(definition.schema);
|
||||
// Preserve workflow input names in the JSON configuration.
|
||||
agent.schema = structuredClone(definition.schema);
|
||||
agent.tools = deepCopy(definition.tools);
|
||||
agent.skills = deepCopy(definition.skills);
|
||||
if (agent.schema) agent.name = agent.schema.name;
|
||||
|
|
|
|||
|
|
@ -836,6 +836,7 @@ export class AgentRuntimeReconstructionService {
|
|||
);
|
||||
const context = {
|
||||
workflowLoader: Container.get(WorkflowToolWorkflowLoader),
|
||||
executor: this.ephemeralNodeExecutor,
|
||||
workflowRunner: await getWorkflowRunner(),
|
||||
subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker),
|
||||
activeExecutions: this.activeExecutions,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
import { zodSchemaToJsonSchema } from '@n8n/ai-utilities/json-schema';
|
||||
|
||||
import {
|
||||
getFixedWorkflowToolInputs,
|
||||
inferInputSchema,
|
||||
listWorkflowInputFields,
|
||||
mergeWorkflowToolInput,
|
||||
omitFixedFieldsFromSchema,
|
||||
buildWorkflowToolInputSchema,
|
||||
} from '../workflow-tool-factory';
|
||||
|
||||
function makeExecuteWorkflowTrigger(parameters: INode['parameters']): INode {
|
||||
|
|
@ -142,43 +143,55 @@ describe('inferInputSchema — executeWorkflow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fixed workflow tool inputs', () => {
|
||||
it('extracts fixed values and omits them from the LLM schema', () => {
|
||||
describe('configured workflow tool inputs', () => {
|
||||
it('advertises AI inputs and merges configured values', () => {
|
||||
const schema = z.object({
|
||||
chatId: z.string().optional(),
|
||||
shoppingListId: z.string().optional(),
|
||||
botName: z.string().optional(),
|
||||
count: z.number().optional(),
|
||||
});
|
||||
const inputs = {
|
||||
shoppingListId: { mode: 'fixed' as const, value: 'OySx3QNU0BcCs8yz' },
|
||||
botName: { mode: 'fixed' as const, value: 'Jarvis' },
|
||||
chatId: { mode: 'ai' as const },
|
||||
['__proto__']: { mode: 'fixed' as const, value: { source: 'configured' } },
|
||||
chatId: { mode: 'ai' as const, description: 'Input guidance' },
|
||||
count: { mode: 'expression' as const, value: '={{ 2 }}' },
|
||||
constructor: { mode: 'ai' as const, description: 'Removed input' },
|
||||
};
|
||||
|
||||
expect(getFixedWorkflowToolInputs(inputs)).toEqual({
|
||||
const fixedInputs = getFixedWorkflowToolInputs(inputs);
|
||||
expect(fixedInputs).toEqual({
|
||||
shoppingListId: 'OySx3QNU0BcCs8yz',
|
||||
botName: 'Jarvis',
|
||||
['__proto__']: { source: 'configured' },
|
||||
});
|
||||
expect(Object.getPrototypeOf(fixedInputs)).toBe(Object.prototype);
|
||||
|
||||
const llmSchema = omitFixedFieldsFromSchema(schema, inputs);
|
||||
const llmSchema = buildWorkflowToolInputSchema(schema, inputs);
|
||||
expect(Object.keys(llmSchema.shape)).toEqual(['chatId']);
|
||||
expect(zodSchemaToJsonSchema(llmSchema)).toMatchObject({
|
||||
properties: { chatId: { description: inputs.chatId.description } },
|
||||
});
|
||||
expect(
|
||||
mergeWorkflowToolInput(
|
||||
llmSchema.parse({ chatId: '42' }) as Record<string, unknown>,
|
||||
inputs,
|
||||
schema,
|
||||
{ count: 2 },
|
||||
),
|
||||
).toEqual({
|
||||
chatId: '42',
|
||||
shoppingListId: 'OySx3QNU0BcCs8yz',
|
||||
botName: 'Jarvis',
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves catchall when omitting fixed keys from an open schema', () => {
|
||||
const schema = z.object({}).catchall(z.unknown());
|
||||
const inputs = { pinned: { mode: 'fixed' as const, value: 'yes' } };
|
||||
const llmSchema = omitFixedFieldsFromSchema(schema, inputs);
|
||||
const llmSchema = buildWorkflowToolInputSchema(schema, inputs);
|
||||
expect(llmSchema.parse({ anything: 1 })).toEqual({ anything: 1 });
|
||||
expect(mergeWorkflowToolInput({ anything: 1 }, inputs, schema)).toEqual({
|
||||
anything: 1,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
|
||||
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
|
||||
import { WebhookResponseRelay } from '@/scaling/webhook-response-relay';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ const workflow = {
|
|||
function buildContext(run: ReturnType<typeof vi.fn>, extras: Partial<WorkflowToolContext> = {}) {
|
||||
return {
|
||||
workflowLoader: {} as never,
|
||||
executor: mock<EphemeralNodeExecutor>(),
|
||||
workflowRunner: { run } as unknown as WorkflowRunner,
|
||||
subworkflowPolicyChecker: mock<SubworkflowPolicyChecker>(),
|
||||
activeExecutions: { has: vi.fn().mockReturnValue(false) } as unknown as ActiveExecutions,
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ describe('WorkflowToolWorkflowLoader', () => {
|
|||
const { service, workflowRepository } = makeService();
|
||||
workflowRepository.findOneByAgentToolReference.mockResolvedValue(
|
||||
makeWorkflow({
|
||||
versionId: 'draft-version',
|
||||
activeVersion: {
|
||||
versionId: 'published-version',
|
||||
nodes: [{ id: 'published-node' }],
|
||||
connections: { Published: {} },
|
||||
},
|
||||
|
|
@ -85,6 +87,7 @@ describe('WorkflowToolWorkflowLoader', () => {
|
|||
{ withActiveVersion: true },
|
||||
);
|
||||
expect(workflow).toMatchObject({
|
||||
versionId: 'published-version',
|
||||
nodes: [{ id: 'published-node' }],
|
||||
connections: { Published: {} },
|
||||
});
|
||||
|
|
@ -113,10 +116,13 @@ describe('WorkflowToolWorkflowLoader', () => {
|
|||
const { service, workflowRepository, workflowPublishedDataService } = makeService({
|
||||
useWorkflowPublicationService: true,
|
||||
});
|
||||
workflowRepository.findOneByAgentToolReference.mockResolvedValue(makeWorkflow());
|
||||
workflowRepository.findOneByAgentToolReference.mockResolvedValue(
|
||||
makeWorkflow({ versionId: 'draft-version' }),
|
||||
);
|
||||
workflowPublishedDataService.getPublishedWorkflowData.mockResolvedValue({
|
||||
workflow: makeWorkflow(),
|
||||
publishedVersion: {
|
||||
versionId: 'published-version',
|
||||
nodes: [{ id: 'published-node' }],
|
||||
connections: { Published: {} },
|
||||
},
|
||||
|
|
@ -129,6 +135,9 @@ describe('WorkflowToolWorkflowLoader', () => {
|
|||
expect(workflowPublishedDataService.getPublishedWorkflowData).toHaveBeenCalledWith(
|
||||
'workflow-1',
|
||||
);
|
||||
expect(workflow).toMatchObject({ nodes: [{ id: 'published-node' }] });
|
||||
expect(workflow).toMatchObject({
|
||||
versionId: 'published-version',
|
||||
nodes: [{ id: 'published-node' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
createRunExecutionData,
|
||||
isTerminalExecutionStatus,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
TimeoutExecutionCancelledError,
|
||||
isIndefiniteWait,
|
||||
} from 'n8n-workflow';
|
||||
|
|
@ -40,6 +41,7 @@ import { z } from 'zod';
|
|||
import type { ActiveExecutions } from '@/active-executions';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
|
||||
import type { EphemeralNodeExecutor } from '@/node-execution/ephemeral-node-executor';
|
||||
import { WebhookResponseRelay } from '@/scaling/webhook-response-relay';
|
||||
import type { WorkflowRunner } from '@/workflow-runner';
|
||||
|
||||
|
|
@ -151,6 +153,7 @@ export type WorkflowToolExecutionMode = Extract<WorkflowExecuteMode, 'manual' |
|
|||
|
||||
export interface WorkflowToolContext {
|
||||
workflowLoader: WorkflowToolWorkflowLoader;
|
||||
executor: EphemeralNodeExecutor;
|
||||
workflowRunner: WorkflowRunner;
|
||||
subworkflowPolicyChecker: SubworkflowPolicyChecker;
|
||||
activeExecutions: ActiveExecutions;
|
||||
|
|
@ -362,29 +365,29 @@ export function getFixedWorkflowToolInputs(
|
|||
inputs: WorkflowToolInputsConfig | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!inputs) return {};
|
||||
const fixed: Record<string, unknown> = {};
|
||||
const fixed: Array<[string, unknown]> = [];
|
||||
for (const [name, binding] of Object.entries(inputs)) {
|
||||
if (binding.mode === 'fixed') {
|
||||
fixed[name] = binding.value;
|
||||
fixed.push([name, binding.value]);
|
||||
}
|
||||
}
|
||||
return fixed;
|
||||
return Object.fromEntries(fixed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop fixed-bound keys from the LLM-facing schema so the model is not asked
|
||||
* for values the user already configured.
|
||||
*/
|
||||
export function omitFixedFieldsFromSchema(
|
||||
/** Advertise AI inputs and their guidance. Configured values stay out of the model schema. */
|
||||
export function buildWorkflowToolInputSchema(
|
||||
schema: z.ZodObject<z.ZodRawShape>,
|
||||
inputs: WorkflowToolInputsConfig | undefined,
|
||||
): z.ZodObject<z.ZodRawShape> {
|
||||
const fixedKeys = Object.keys(getFixedWorkflowToolInputs(inputs));
|
||||
if (fixedKeys.length === 0) return schema;
|
||||
if (!inputs) return schema;
|
||||
|
||||
const shape = { ...schema.shape };
|
||||
for (const key of fixedKeys) {
|
||||
delete shape[key];
|
||||
for (const [name, binding] of Object.entries(inputs)) {
|
||||
if (binding.mode !== 'ai') {
|
||||
delete shape[name];
|
||||
} else if (Object.hasOwn(shape, name) && binding.description?.trim()) {
|
||||
shape[name] = shape[name].describe(binding.description.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const catchall = schema._def.catchall as z.ZodTypeAny | undefined;
|
||||
|
|
@ -394,25 +397,57 @@ export function omitFixedFieldsFromSchema(
|
|||
return z.object(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge LLM-supplied args with fixed tool-config values (fixed wins), then
|
||||
* parse the result against the full declared schema so fixed values are
|
||||
* coerced to their field's declared type (e.g. numeric IDs stored as strings
|
||||
* are converted to numbers for number fields). Without this, fixed bindings
|
||||
* bypass the schema and reach the sub-workflow with the wrong runtime type.
|
||||
*/
|
||||
/** Merge configured values over model arguments. Validate and coerce them to the trigger types. */
|
||||
export function mergeWorkflowToolInput(
|
||||
llmInput: Record<string, unknown>,
|
||||
inputs: WorkflowToolInputsConfig | undefined,
|
||||
fullSchema: z.ZodObject<z.ZodRawShape>,
|
||||
expressionValues: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {
|
||||
...llmInput,
|
||||
...getFixedWorkflowToolInputs(inputs),
|
||||
...expressionValues,
|
||||
};
|
||||
return fullSchema.parse(merged);
|
||||
}
|
||||
|
||||
async function resolveWorkflowToolInput(
|
||||
input: Record<string, unknown>,
|
||||
inputs: WorkflowToolInputsConfig | undefined,
|
||||
fullSchema: z.ZodObject<z.ZodRawShape>,
|
||||
context: WorkflowToolContext,
|
||||
toolName: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const modelInput: IDataObject = buildWorkflowToolInputSchema(fullSchema, inputs).parse(input);
|
||||
const expressions: Record<string, string> = {};
|
||||
const acceptsExtraInputs = !(fullSchema._def.catchall instanceof z.ZodNever);
|
||||
for (const [name, binding] of Object.entries(inputs ?? {})) {
|
||||
if (
|
||||
binding.mode === 'expression' &&
|
||||
(Object.hasOwn(fullSchema.shape, name) || acceptsExtraInputs)
|
||||
) {
|
||||
expressions[name] = binding.value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(expressions).length === 0) {
|
||||
return mergeWorkflowToolInput(modelInput, inputs, fullSchema);
|
||||
}
|
||||
|
||||
const resolved = await context.executor.evaluateExpressions(
|
||||
{
|
||||
projectId: context.projectId,
|
||||
nodeType: WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
nodeTypeVersion: 2.2,
|
||||
nodeParameters: {},
|
||||
nodeName: toolName,
|
||||
},
|
||||
expressions,
|
||||
[{ json: modelInput }],
|
||||
);
|
||||
return mergeWorkflowToolInput(modelInput, inputs, fullSchema, resolved);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. executeWorkflow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1006,7 +1041,7 @@ async function buildWorkflowTool(
|
|||
|
||||
return assembleWorkflowTool(descriptor, context, {
|
||||
reference: { workflowId: workflow.id, workflowName: workflow.name },
|
||||
inputSchema: omitFixedFieldsFromSchema(fullInputSchema, descriptor.inputs),
|
||||
inputSchema: buildWorkflowToolInputSchema(fullInputSchema, descriptor.inputs),
|
||||
triggerType,
|
||||
});
|
||||
}
|
||||
|
|
@ -1055,7 +1090,7 @@ function assembleWorkflowTool(
|
|||
// A continuation means this workflow already ran on without us, so re-running
|
||||
// it would be wrong. Skip the reload too: a settled run's output should still
|
||||
// come back even if the workflow was archived since. The input is not parsed
|
||||
// again either — fixed tool inputs were merged in on the original call.
|
||||
// again either — configured inputs were resolved on the original call.
|
||||
const pending = WAIT_CONTINUATION_SCHEMA.safeParse(ctx.continuation);
|
||||
let current: Awaited<ReturnType<typeof loadCurrentWorkflow>> | undefined;
|
||||
let result: WorkflowToolExecutionResult;
|
||||
|
|
@ -1065,11 +1100,12 @@ function assembleWorkflowTool(
|
|||
} else {
|
||||
current = await loadCurrentWorkflow(context, reference);
|
||||
const currentFullSchema = inferInputSchema(current.triggerNode, current.triggerType);
|
||||
const currentSchema = omitFixedFieldsFromSchema(currentFullSchema, toolInputs);
|
||||
const parsedInput = mergeWorkflowToolInput(
|
||||
currentSchema.parse(input),
|
||||
const parsedInput = await resolveWorkflowToolInput(
|
||||
input,
|
||||
toolInputs,
|
||||
currentFullSchema,
|
||||
context,
|
||||
toolName,
|
||||
);
|
||||
result = await executeWorkflow(
|
||||
current.workflow,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ export class WorkflowToolWorkflowLoader {
|
|||
`Workflow "${workflow.name}" is not published. Publish it so the published agent can use it.`,
|
||||
);
|
||||
}
|
||||
Object.assign(workflow, { nodes: published.nodes, connections: published.connections });
|
||||
Object.assign(workflow, {
|
||||
versionId: published.versionId,
|
||||
nodes: published.nodes,
|
||||
connections: published.connections,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.assign(workflow, { pinData: undefined });
|
||||
|
|
|
|||
|
|
@ -947,7 +947,7 @@ describe('EphemeralNodeExecutor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('resolves expressions when invoking a supplyData tool with the VM engine', async () => {
|
||||
it('resolves expressions in standalone tool contexts with the VM engine', async () => {
|
||||
await Expression.initExpressionEngine({
|
||||
engine: 'vm',
|
||||
bridgeTimeout: 1000,
|
||||
|
|
@ -993,6 +993,38 @@ describe('EphemeralNodeExecutor', () => {
|
|||
status: 'success',
|
||||
data: [{ json: { response: 2 } }],
|
||||
});
|
||||
|
||||
mockGetBase.mockResolvedValue({ variables: { increment: '3' } });
|
||||
const tool = {
|
||||
nodeType: '@n8n/n8n-nodes-langchain.toolWorkflow',
|
||||
nodeTypeVersion: 2.2,
|
||||
nodeParameters: {},
|
||||
projectId: 'p-1',
|
||||
};
|
||||
await expect(
|
||||
executor.evaluateExpressions(
|
||||
tool,
|
||||
{
|
||||
count: '={{ $json.count + Number($vars.increment) }}',
|
||||
options: '={{ { enabled: true, items: [1, 2] } }}',
|
||||
date: '={{ $now.toISODate() }}',
|
||||
},
|
||||
[{ json: { count: 4 } }],
|
||||
),
|
||||
).resolves.toEqual({
|
||||
count: 7,
|
||||
options: { enabled: true, items: [1, 2] },
|
||||
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
});
|
||||
await expect(
|
||||
executor.evaluateExpressions(tool, { broken: '={{ 1 + }}' }, []),
|
||||
).rejects.toThrow('Cannot resolve input "broken"');
|
||||
// A failed expression must release the single isolate for the next call.
|
||||
await expect(
|
||||
executor.evaluateExpressions(tool, { count: '={{ $json.count }}' }, [
|
||||
{ json: { count: 9 } },
|
||||
]),
|
||||
).resolves.toEqual({ count: 9 });
|
||||
} finally {
|
||||
await Expression.disposeExpressionEngine();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,42 @@ export class EphemeralNodeExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/** Resolve input expressions in the same context as a standalone node tool. */
|
||||
async evaluateExpressions(
|
||||
tool: EphemeralWorkflowToolLike,
|
||||
expressions: Record<string, string>,
|
||||
inputItems: INodeExecutionData[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const parts = await this.buildEphemeralContextParts(tool, inputItems);
|
||||
const context = new ExecuteContext(
|
||||
parts.workflow,
|
||||
parts.node,
|
||||
parts.additionalData,
|
||||
parts.mode,
|
||||
parts.runExecutionData,
|
||||
0,
|
||||
inputItems,
|
||||
parts.inputData,
|
||||
parts.executeData,
|
||||
[],
|
||||
);
|
||||
|
||||
return await withExpressionIsolate(parts.workflow, async () => {
|
||||
const resolved: Record<string, unknown> = {};
|
||||
for (const [name, expression] of Object.entries(expressions)) {
|
||||
try {
|
||||
// evaluateExpression adds the leading '=' itself.
|
||||
resolved[name] = context.evaluateExpression(expression.slice(1));
|
||||
} catch (error) {
|
||||
throw new UserError(`Cannot resolve input "${name}": ${getErrorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
async executeInline(request: InlineNodeExecutionRequest): Promise<NodeExecutionResult> {
|
||||
// Validation failures (unknown node type, trigger nodes, blacklisted
|
||||
// operations like send-and-wait) need to surface to the agent as a
|
||||
|
|
|
|||
|
|
@ -212,6 +212,7 @@ export async function getPublishedWorkflowData(
|
|||
}
|
||||
return {
|
||||
...publishedData.workflow,
|
||||
versionId: publishedData.publishedVersion.versionId,
|
||||
nodes: publishedData.publishedVersion.nodes,
|
||||
connections: publishedData.publishedVersion.connections,
|
||||
};
|
||||
|
|
@ -232,6 +233,7 @@ export async function getPublishedWorkflowData(
|
|||
if (workflowData && 'activeVersion' in workflowData && workflowData.activeVersion) {
|
||||
return {
|
||||
...workflowData,
|
||||
versionId: workflowData.activeVersion.versionId,
|
||||
nodes: workflowData.activeVersion.nodes,
|
||||
connections: workflowData.activeVersion.connections,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2797,7 +2797,8 @@
|
|||
"parameterInputList.callout.dismiss.confirm.confirmButtonText": "Confirm",
|
||||
"parameterInputList.callout.dismiss.confirm.cancelButtonText": "Cancel",
|
||||
"parameterInputList.autoRequiresChatTriggerDescription": "Available when connected to a Chat Trigger node.",
|
||||
"parameterOverride.overridePanelText": "Defined automatically by the <b>model</b>",
|
||||
"parameterOverride.overridePanelText": "Defined by agent",
|
||||
"parameterOverride.editValue": "Edit value",
|
||||
"parameterOverride.applyOverrideButtonTooltip": "Let the model define this parameter",
|
||||
"parameterOverride.descriptionTooltip": "Explain to the LLM how it should generate this value, a good, specific description would allow LLMs to produce expected results much more often",
|
||||
"personalizationModal.businessOwner": "Business Owner",
|
||||
|
|
@ -8303,15 +8304,14 @@
|
|||
"agents.toolConfig.workflow.target.idNotFound": "No workflow with this ID can be used here. It has to be in this project and start with a '{trigger}' trigger.",
|
||||
"agents.toolConfig.workflow.target.open": "Open workflow in a new tab",
|
||||
"agents.toolConfig.workflow.inputs": "Workflow inputs",
|
||||
"agents.toolConfig.workflow.inputs.hint": "The agent fills in these fields when it calls the workflow. Set a fixed value for any field the agent should not choose.",
|
||||
"agents.toolConfig.workflow.inputs.mode.ai": "Determined by agent",
|
||||
"agents.toolConfig.workflow.inputs.mode.fixed": "Fixed value",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder": "Enter a fixed value",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.string": "Enter text",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.number": "Enter a number",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.boolean": "true or false",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.array": "Enter a JSON array, e.g. [\"a\", \"b\"]",
|
||||
"agents.toolConfig.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}",
|
||||
"agents.toolConfig.workflow.advanced": "Advanced",
|
||||
"agents.toolConfig.workflow.inputs.description": "Description (optional)",
|
||||
"agents.toolConfig.workflow.inputs.description.placeholder": "Tell the agent what value to use",
|
||||
"agents.toolConfig.workflow.inputs.empty": "This workflow has no defined inputs. Define inputs in its trigger to configure them here.",
|
||||
"agents.toolConfig.workflow.inputs.loading": "Loading workflow inputs…",
|
||||
"agents.toolConfig.workflow.inputs.loadError": "Couldn't load workflow inputs. Try again.",
|
||||
"agents.toolConfig.workflow.inputs.expression.empty": "Enter an expression",
|
||||
"agents.toolConfig.workflow.inputs.value.invalid": "Enter a valid {type} value",
|
||||
"agents.toolConfig.validation.fixFields": "Fix the highlighted fields before saving.",
|
||||
"agents.toolConfig.approval.label": "Require approval",
|
||||
"agents.toolConfig.approval.hint": "Pause before this tool runs until a human approves it.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ export const ExecutionDataStoreKey: InjectionKey<
|
|||
export const CanvasRenderDataKey: InjectionKey<Ref<CanvasRenderData>> = Symbol('CanvasRenderData');
|
||||
/** Keep setup hints compact while retaining their full text on the field. */
|
||||
export const CompactParameterHintsKey: InjectionKey<boolean> = Symbol('CompactParameterHints');
|
||||
/** Lets modal hosts coordinate focus and portals for expanded parameter editors. */
|
||||
export const ParameterInputModalContextKey: InjectionKey<{
|
||||
openDialogs: Ref<Set<string>>;
|
||||
appendTo: string;
|
||||
}> = Symbol('ParameterInputModalContext');
|
||||
/**
|
||||
* Opts resource-locator dropdowns into teleporting to `<body>`. Defaults to
|
||||
* `false` (stay in the local stacking context, e.g. inside the NDV dialog).
|
||||
|
|
|
|||
|
|
@ -2586,6 +2586,15 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
|
|||
});
|
||||
|
||||
it('refreshes runnable state from the backend after saving manual config edits', async () => {
|
||||
const workflowTool: AgentJsonToolRef = {
|
||||
type: 'workflow',
|
||||
workflow: 'Test workflow',
|
||||
inputs: {
|
||||
constructor: { mode: 'ai' as const, description: 'Use the label.' },
|
||||
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
|
||||
},
|
||||
};
|
||||
intendedConfig!.tools = [workflowTool];
|
||||
getAgentMock
|
||||
.mockResolvedValueOnce(makeAgentResponse({ isRunnable: false }))
|
||||
.mockResolvedValueOnce(makeAgentResponse({ isRunnable: true, versionId: 'v2' }));
|
||||
|
|
@ -2608,7 +2617,12 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
|
|||
await vm.flushAutosave();
|
||||
await nextTick();
|
||||
|
||||
expect(updateConfigMock).toHaveBeenCalled();
|
||||
expect(updateConfigMock).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
'a1',
|
||||
expect.objectContaining({ tools: [workflowTool] }),
|
||||
'hash-1',
|
||||
);
|
||||
expect(getAgentMock).toHaveBeenLastCalledWith({ baseUrl: 'http://localhost:5678' }, 'p1', 'a1');
|
||||
expect(vm.isBuilt).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { N8nDialog } from '@n8n/design-system';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, inject, Teleport } from 'vue';
|
||||
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
|
||||
|
||||
import AgentModal from '../components/modals/AgentModal.vue';
|
||||
import AgentModalMultiStep from '../components/modals/AgentModalMultiStep.vue';
|
||||
|
|
@ -185,6 +188,60 @@ describe('AgentModal', () => {
|
|||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('releases and restores focus and dismissal for a nested parameter editor', async () => {
|
||||
const editor = defineComponent({
|
||||
setup() {
|
||||
const context = inject(ParameterInputModalContextKey)!;
|
||||
return () => [
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-test-id': 'open-editor',
|
||||
onClick: () => context.openDialogs.value.add('expression'),
|
||||
},
|
||||
'Open editor',
|
||||
),
|
||||
h(
|
||||
Teleport,
|
||||
{ to: document.body },
|
||||
h('input', {
|
||||
'data-test-id': 'parameter-editor-input',
|
||||
onKeydown: () => context.openDialogs.value.delete('expression'),
|
||||
}),
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
const wrapper = mount(AgentModal, {
|
||||
props: { open: true, title: 'Tool settings' },
|
||||
slots: { default: () => h(editor) },
|
||||
});
|
||||
const dialog = wrapper.get('[role="dialog"]');
|
||||
await wrapper.get('[data-test-id="open-editor"]').trigger('click');
|
||||
expect(dialog.attributes('data-trap-focus')).toBe('false');
|
||||
expect(dialog.attributes('data-disable-outside-pointer-events')).toBe('false');
|
||||
await wrapper.get('[data-test-id="dialog-escape"]').trigger('click');
|
||||
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
|
||||
expect(dialog.attributes('data-escape-prevented')).toBe('true');
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
|
||||
const escape = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.querySelector('[data-test-id="parameter-editor-input"]')!.dispatchEvent(escape);
|
||||
wrapper.getComponent(N8nDialog).vm.$emit('escapeKeyDown', escape);
|
||||
await flushPromises();
|
||||
expect(escape.defaultPrevented).toBe(true);
|
||||
expect(wrapper.emitted('update:open')).toBeUndefined();
|
||||
expect(dialog.attributes('data-trap-focus')).toBe('true');
|
||||
expect(dialog.attributes('data-disable-outside-pointer-events')).toBe('true');
|
||||
await wrapper.get('[data-test-id="dialog-dismiss"]').trigger('click');
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits Back and Close when the modal is idle', async () => {
|
||||
const wrapper = mountModal({ showBack: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types';
|
||||
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
import WorkflowToolConfigContent from '../components/WorkflowToolConfigContent.vue';
|
||||
import WorkflowToolInputs from '../components/WorkflowToolInputs.vue';
|
||||
import AgentToolConfigForm from '../components/AgentToolConfigForm.vue';
|
||||
import type { WorkflowToolRef } from '../types';
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({ params: {}, query: {}, meta: {} }),
|
||||
useRouter: () => ({
|
||||
resolve: ({ params }: { params: { workflowId: string } }) => ({
|
||||
href: `/workflow/${params.workflowId}`,
|
||||
|
|
@ -21,11 +26,11 @@ vi.mock('vue-router', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('@n8n/i18n', () => {
|
||||
const i18n = {
|
||||
baseText: (key: string) => key,
|
||||
};
|
||||
return { useI18n: () => i18n, i18n, i18nInstance: { install: vi.fn() } };
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@n8n/i18n')>();
|
||||
const i18n = Object.create(actual.i18n) as typeof actual.i18n;
|
||||
i18n.baseText = (key: string) => key;
|
||||
return { ...actual, useI18n: () => i18n, i18n };
|
||||
});
|
||||
|
||||
// Element Plus' select is unusable in jsdom, so only the select/option pair is
|
||||
|
|
@ -86,6 +91,27 @@ function workflow(
|
|||
|
||||
const renderComponent = createComponentRenderer(WorkflowToolConfigContent);
|
||||
|
||||
function renderForm(initialRef: WorkflowToolRef) {
|
||||
const form = ref<InstanceType<typeof AgentToolConfigForm>>();
|
||||
const onConfirm = vi.fn();
|
||||
const render = createComponentRenderer(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h('div', [
|
||||
h(AgentToolConfigForm, { ref: form, data: { toolRef: initialRef, onConfirm } }),
|
||||
h('button', { onClick: () => form.value?.confirm() }, 'Save'),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
return { ...render(), onConfirm, form };
|
||||
}
|
||||
|
||||
function editExpression(field: HTMLElement, expression: string) {
|
||||
const content = field.querySelector<HTMLElement>('.cm-content')!;
|
||||
const editor = EditorView.findFromDOM(content)!;
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: expression } });
|
||||
}
|
||||
|
||||
function createRef(overrides: Partial<WorkflowToolRef> = {}): WorkflowToolRef {
|
||||
return {
|
||||
type: 'workflow',
|
||||
|
|
@ -382,9 +408,11 @@ describe('WorkflowToolConfigContent', () => {
|
|||
]),
|
||||
]);
|
||||
|
||||
const { findByTestId } = renderComponent({ props: { initialRef: createRef() } });
|
||||
const { findByTestId, getByTestId } = renderComponent({ props: { initialRef: createRef() } });
|
||||
|
||||
expect(await findByTestId('agent-workflow-tool-inputs')).toBeTruthy();
|
||||
expect(await findByTestId('agent-workflow-tool-inputs')).not.toBeVisible();
|
||||
await fireEvent.click(getByTestId('agent-workflow-tool-advanced'));
|
||||
expect(getByTestId('agent-workflow-tool-inputs')).toBeVisible();
|
||||
expect(await findByTestId('agent-workflow-tool-input-chatId')).toBeTruthy();
|
||||
expect(await findByTestId('agent-workflow-tool-input-botName')).toBeTruthy();
|
||||
});
|
||||
|
|
@ -405,12 +433,160 @@ describe('WorkflowToolConfigContent', () => {
|
|||
},
|
||||
});
|
||||
|
||||
await findByTestId('agent-workflow-tool-input-value-botName');
|
||||
expect(
|
||||
(getByTestId('agent-workflow-tool-input-value-botName') as HTMLInputElement).value,
|
||||
).toBe('Jarvis');
|
||||
const field = await findByTestId('agent-workflow-tool-input-botName');
|
||||
await fireEvent.click(getByTestId('agent-workflow-tool-advanced'));
|
||||
expect(within(field).getByRole('textbox')).toHaveValue('Jarvis');
|
||||
});
|
||||
|
||||
it.each(['constructor', 'toString', 'hasOwnProperty', '__proto__'])(
|
||||
'configures and reopens an input named %s',
|
||||
async (name) => {
|
||||
const inputs = shallowRef<NonNullable<WorkflowToolRef['inputs']>>({});
|
||||
const inputType = ref('string');
|
||||
const render = createComponentRenderer(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(WorkflowToolInputs, {
|
||||
fields: [{ name, type: inputType.value }],
|
||||
toolName: 'Notify Sales',
|
||||
submitted: false,
|
||||
modelValue: inputs.value,
|
||||
'onUpdate:modelValue': (updated: NonNullable<WorkflowToolRef['inputs']>) => {
|
||||
inputs.value = updated;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const view = render();
|
||||
const field = view.getByTestId(`agent-workflow-tool-input-${name}`);
|
||||
await fireEvent.click(
|
||||
within(field).getByRole('button', {
|
||||
name: 'parameterOverride.editValue',
|
||||
}),
|
||||
);
|
||||
expect(within(field).getByRole('textbox')).toHaveValue('');
|
||||
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: '' } });
|
||||
await fireEvent.update(within(field).getByRole('textbox'), 'Literal value');
|
||||
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: 'Literal value' } });
|
||||
|
||||
view.unmount();
|
||||
const reopened = render();
|
||||
const savedField = reopened.getByTestId(`agent-workflow-tool-input-${name}`);
|
||||
await waitFor(() =>
|
||||
expect(within(savedField).getByRole('textbox')).toHaveValue('Literal value'),
|
||||
);
|
||||
await fireEvent.click(
|
||||
within(savedField).getByRole('button', {
|
||||
name: 'parameterOverride.applyOverrideButtonTooltip',
|
||||
}),
|
||||
);
|
||||
expect(inputs.value).toEqual({});
|
||||
await fireEvent.update(within(savedField).getByRole('textbox'), 'Input guidance');
|
||||
expect(inputs.value).toEqual({ [name]: { mode: 'ai', description: 'Input guidance' } });
|
||||
|
||||
inputType.value = 'object';
|
||||
await nextTick();
|
||||
await fireEvent.click(
|
||||
within(savedField).getByRole('button', { name: 'parameterOverride.editValue' }),
|
||||
);
|
||||
const rawValue = '{ "answer": 42 }';
|
||||
editExpression(savedField, rawValue);
|
||||
await waitFor(() =>
|
||||
expect(inputs.value).toEqual({ [name]: { mode: 'fixed', value: { answer: 42 } } }),
|
||||
);
|
||||
await nextTick();
|
||||
const content = savedField.querySelector<HTMLElement>('.cm-content')!;
|
||||
expect(EditorView.findFromDOM(content)?.state.doc.toString()).toBe(rawValue);
|
||||
},
|
||||
);
|
||||
|
||||
it('saves and reopens all binding modes without changing the original reference', async () => {
|
||||
setProjectWorkflows([
|
||||
workflowWithInputs('Notify Sales', [
|
||||
{ name: 'query', type: 'string' },
|
||||
{ name: 'label', type: 'string' },
|
||||
{ name: 'count', type: 'number' },
|
||||
]),
|
||||
]);
|
||||
const initialRef = createRef();
|
||||
const view = renderForm(initialRef);
|
||||
await view.findByTestId('agent-workflow-tool-inputs');
|
||||
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
|
||||
await fireEvent.update(
|
||||
within(view.getByTestId('agent-workflow-tool-input-query')).getByRole('textbox'),
|
||||
'Input guidance',
|
||||
);
|
||||
const label = within(view.getByTestId('agent-workflow-tool-input-label'));
|
||||
await fireEvent.click(label.getByRole('button', { name: 'parameterOverride.editValue' }));
|
||||
await fireEvent.update(label.getByRole('textbox'), '={{ literal }}');
|
||||
const count = view.getByTestId('agent-workflow-tool-input-count');
|
||||
await fireEvent.click(
|
||||
within(count).getByRole('button', { name: 'parameterOverride.editValue' }),
|
||||
);
|
||||
await fireEvent.click(within(count).getByText('parameterInput.expression'));
|
||||
editExpression(count, '{{ $json.query.length }}');
|
||||
await waitFor(() => expect(view.form.value?.canSave).toBe(true));
|
||||
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
|
||||
expect(view.onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputs: {
|
||||
query: { mode: 'ai', description: 'Input guidance' },
|
||||
label: { mode: 'fixed', value: '={{ literal }}' },
|
||||
count: { mode: 'expression', value: '={{ $json.query.length }}' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(initialRef.inputs).toBeUndefined();
|
||||
const saved = view.onConfirm.mock.calls[0][0] as WorkflowToolRef;
|
||||
view.unmount();
|
||||
const reopened = renderForm(saved);
|
||||
await reopened.findByTestId('agent-workflow-tool-inputs');
|
||||
await fireEvent.click(reopened.getByTestId('agent-workflow-tool-advanced'));
|
||||
expect(
|
||||
within(reopened.getByTestId('agent-workflow-tool-input-query')).getByRole('textbox'),
|
||||
).toHaveValue('Input guidance');
|
||||
expect(
|
||||
within(reopened.getByTestId('agent-workflow-tool-input-label')).getByRole('textbox'),
|
||||
).toHaveValue('={{ literal }}');
|
||||
expect(reopened.getByTestId('agent-workflow-tool-input-count')).toHaveTextContent(
|
||||
'$json.query.length',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: 'expression', value: '=' },
|
||||
{ mode: 'fixed', value: 'not a number' },
|
||||
] as const)(
|
||||
'validates $mode inputs on Save and preserves them when loading fails',
|
||||
async (binding) => {
|
||||
const inputs: WorkflowToolRef['inputs'] = { count: binding };
|
||||
setProjectWorkflows([
|
||||
workflowWithInputs('Notify Sales', [{ name: 'count', type: 'number' }]),
|
||||
]);
|
||||
const view = renderForm(createRef({ inputs }));
|
||||
await view.findByTestId('agent-workflow-tool-inputs');
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
|
||||
expect(view.onConfirm).not.toHaveBeenCalled();
|
||||
expect(view.getByTestId('agent-workflow-tool-advanced')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
await fireEvent.click(view.getByTestId('agent-workflow-tool-advanced'));
|
||||
}
|
||||
view.unmount();
|
||||
mockedStore(useWorkflowsListStore).searchWorkflows = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('offline'));
|
||||
const failedLoad = renderForm(createRef({ inputs }));
|
||||
await fireEvent.click(failedLoad.getByTestId('agent-workflow-tool-advanced'));
|
||||
await failedLoad.findByText('agents.toolConfig.workflow.inputs.loadError');
|
||||
await fireEvent.click(failedLoad.getByRole('button', { name: 'Save' }));
|
||||
expect(failedLoad.onConfirm).toHaveBeenCalledWith(expect.objectContaining({ inputs }));
|
||||
},
|
||||
);
|
||||
|
||||
it('does not render input bindings for passthrough workflows', async () => {
|
||||
setProjectWorkflows([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
import {
|
||||
configToDoc,
|
||||
getSlice,
|
||||
|
|
@ -45,10 +46,21 @@ describe('agentSectionEditor.utils', () => {
|
|||
});
|
||||
|
||||
describe('setSlice', () => {
|
||||
it('does not mutate the input', () => {
|
||||
const next = setSlice(cfg, 'name', 'y');
|
||||
it('does not mutate the input or remove workflow input bindings', () => {
|
||||
const tool: NonNullable<AgentJsonConfig['tools']>[number] = {
|
||||
type: 'workflow',
|
||||
workflow: 'Test workflow',
|
||||
inputs: {
|
||||
constructor: { mode: 'ai' as const, description: 'Use the label.' },
|
||||
hasOwnProperty: { mode: 'fixed' as const, value: '=literal' },
|
||||
},
|
||||
};
|
||||
const source = reactive({ ...cfg, tools: [tool] });
|
||||
const next = setSlice(source, 'name', 'y');
|
||||
expect(next.name).toBe('y');
|
||||
expect(cfg.name).toBe('x');
|
||||
expect(source.name).toBe('x');
|
||||
expect(next.tools).toEqual([tool]);
|
||||
expect(next.tools?.[0]).not.toBe(tool);
|
||||
});
|
||||
it('replaces nested object values', () => {
|
||||
const next = setSlice(cfg, 'config.toolCallConcurrency', 8);
|
||||
|
|
|
|||
|
|
@ -109,8 +109,10 @@ Do not add top padding or a top margin to a modal's first content wrapper. The
|
|||
shell supplies that space. Use the flush body only for a full-bleed workspace.
|
||||
The workspace must then own all of its edge spacing.
|
||||
|
||||
When a nested credential dialog is open, release the parent focus trap and
|
||||
block parent dismissal. The nested dialog owns Escape until it closes.
|
||||
When a nested credential or parameter editor dialog is open, release the parent
|
||||
focus trap and block parent dismissal. The nested dialog owns Escape until it
|
||||
closes. Render expanded parameter editors in the body portal above the Agent
|
||||
modal.
|
||||
|
||||
### Title contract
|
||||
|
||||
|
|
@ -228,6 +230,15 @@ Add a section when an Agent-specific pattern applies to two or more Agent
|
|||
surfaces. Keep implementation details with the owning pattern. Do not duplicate
|
||||
global Design System guidance.
|
||||
|
||||
## Model-defined inputs
|
||||
|
||||
Use `ParameterInputFull` for workflow inputs and node tool parameters.
|
||||
It owns the field label, Fixed/Expression controls, AI button, model chip,
|
||||
and hover and focus behavior. Use its controlled input mode for workflow
|
||||
bindings. Keep binding conversion and optional input guidance in the caller.
|
||||
Hide the Edit value action in read-only forms. Keep input issues visible
|
||||
beside the model label.
|
||||
|
||||
## Tool approvals
|
||||
|
||||
Use `N8nApprovalCard` for Preview tool approvals, including background child
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const nodeContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | nul
|
|||
const mcpContentRef = ref<InstanceType<typeof AgentToolConfigNodeContent> | null>(null);
|
||||
const workflowContentRef = ref<InstanceType<typeof AgentToolConfigWorkflowContent> | null>(null);
|
||||
const isValid = ref(false);
|
||||
const submitted = ref(false);
|
||||
const submitCount = ref(0);
|
||||
const approvalRequired = ref(false);
|
||||
const mcpApproval = ref<AgentJsonMcpServerConfig['approval']>();
|
||||
const mcpApprovalValid = ref(true);
|
||||
|
|
@ -235,7 +235,7 @@ function withMcpApproval(server: AgentJsonMcpServerConfig): AgentJsonMcpServerCo
|
|||
}
|
||||
|
||||
function confirm(): boolean {
|
||||
submitted.value = true;
|
||||
submitCount.value += 1;
|
||||
if (!canSave.value) return false;
|
||||
|
||||
if (isCustomTool.value) {
|
||||
|
|
@ -300,7 +300,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
<template>
|
||||
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
|
||||
<N8nText
|
||||
v-if="submitted && !canSave"
|
||||
v-if="submitCount && !canSave"
|
||||
size="small"
|
||||
color="danger"
|
||||
data-testid="agent-tool-config-validation-error"
|
||||
|
|
@ -321,6 +321,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
v-if="workflowInitialRef"
|
||||
ref="workflowContentRef"
|
||||
:initial-ref="workflowInitialRef"
|
||||
:submit-count="submitCount"
|
||||
:project-id="data.projectId"
|
||||
:show-approval-setting="showApprovalSetting"
|
||||
:approval-required="approvalRequired"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const props = defineProps<{
|
|||
projectId?: string;
|
||||
showApprovalSetting?: boolean;
|
||||
approvalRequired?: boolean;
|
||||
submitCount?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -64,6 +65,7 @@ defineExpose({
|
|||
ref="contentRef"
|
||||
:initial-ref="props.initialRef"
|
||||
:project-id="props.projectId"
|
||||
:submit-count="props.submitCount"
|
||||
@update:valid="emit('update:valid', $event)"
|
||||
@update:node-name="emit('update:node-name', $event)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Configure a workflow-type tool on an agent.
|
||||
*
|
||||
* Workflow tools have a very different shape from node tools — no node
|
||||
* parameters, no credentials — so we render a small dedicated form instead
|
||||
* of reusing `NodeToolSettingsContent`. The LLM-facing fields are:
|
||||
* - workflowId (the target workflow's stable lookup key)
|
||||
* - workflow (the target workflow's display name and legacy lookup key)
|
||||
* - name (edited in the modal header's inline-text widget)
|
||||
* - description (what the LLM reads to understand when to use the tool)
|
||||
* - allOutputs (`true` returns every node output; `false` = last node only)
|
||||
* - inputs (optional AI vs fixed bindings for Execute Workflow Trigger fields)
|
||||
*
|
||||
* The underlying workflow's runtime input schema is inferred by
|
||||
* `WorkflowToolFactory.inferInputSchema` at invocation time; this form only
|
||||
* lets the user pin fixed values so the LLM is not asked for them.
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
/** Configure a workflow tool without changing its trigger. */
|
||||
import { computed, onMounted, ref, toRaw, watch } from 'vue';
|
||||
import dateformat from 'dateformat';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInput,
|
||||
N8nOption,
|
||||
|
|
@ -28,23 +14,19 @@ import {
|
|||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { AgentJsonWorkflowToolInputField } from '@n8n/api-types';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useAgentToolCatalog } from '../composables/useAgentToolCatalog';
|
||||
import type { WorkflowToolRef } from '../types';
|
||||
import {
|
||||
formatWorkflowToolFixedValue,
|
||||
listWorkflowToolInputFields,
|
||||
parseWorkflowToolFixedValue,
|
||||
} from '../utils/workflowToolInputFields';
|
||||
import { listWorkflowToolInputFields } from '../utils/workflowToolInputFields';
|
||||
import WorkflowToolInputs from './WorkflowToolInputs.vue';
|
||||
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
|
||||
|
||||
const props = defineProps<{
|
||||
initialRef: WorkflowToolRef;
|
||||
projectId?: string;
|
||||
submitCount?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -64,17 +46,23 @@ const allOutputs = ref(props.initialRef.allOutputs ?? false);
|
|||
const workflow = ref(props.initialRef.workflow ?? '');
|
||||
const workflowId = ref<string | undefined>(props.initialRef.workflowId);
|
||||
const inputs = ref<NonNullable<WorkflowToolRef['inputs']>>({
|
||||
...(props.initialRef.inputs ?? {}),
|
||||
...toRaw(props.initialRef.inputs ?? {}),
|
||||
});
|
||||
const isLoadingWorkflows = ref(true);
|
||||
const workflowsLoaded = ref(false);
|
||||
const inputsValid = ref(true);
|
||||
const advancedExpanded = ref(false);
|
||||
const mode = ref<'list' | 'id'>('list');
|
||||
const enteredId = ref('');
|
||||
const isIdUnresolvable = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadWorkflows(props.projectId);
|
||||
async function reloadWorkflows() {
|
||||
isLoadingWorkflows.value = true;
|
||||
workflowsLoaded.value = await loadWorkflows(props.projectId);
|
||||
isLoadingWorkflows.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(reloadWorkflows);
|
||||
|
||||
watch(
|
||||
() => props.initialRef,
|
||||
|
|
@ -84,30 +72,13 @@ watch(
|
|||
allOutputs.value = updated.allOutputs ?? false;
|
||||
workflow.value = updated.workflow ?? '';
|
||||
workflowId.value = updated.workflowId;
|
||||
inputs.value = { ...(updated.inputs ?? {}) };
|
||||
inputs.value = { ...toRaw(updated.inputs ?? {}) };
|
||||
mode.value = 'list';
|
||||
enteredId.value = '';
|
||||
isIdUnresolvable.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
// Validity gate: target, name and description are required — the description
|
||||
// is what the LLM reads to decide when to invoke the tool, and executing
|
||||
// without one fails. Only allOutputs is free to stay false.
|
||||
watch(
|
||||
[name, description, workflow],
|
||||
([nameValue, descriptionValue, workflowValue]) => {
|
||||
emit(
|
||||
'update:valid',
|
||||
nameValue.trim().length > 0 &&
|
||||
descriptionValue.trim().length > 0 &&
|
||||
workflowValue.trim().length > 0,
|
||||
);
|
||||
emit('update:node-name', nameValue);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function matchesReference(candidate: { id: string; name: string }) {
|
||||
return workflowId.value !== undefined
|
||||
? candidate.id === workflowId.value
|
||||
|
|
@ -130,6 +101,7 @@ const targetWorkflow = computed(() => {
|
|||
/** Target is gone from the project entirely — deleted, moved, or inaccessible. */
|
||||
const isMissing = computed(
|
||||
() =>
|
||||
workflowsLoaded.value &&
|
||||
!isLoadingWorkflows.value &&
|
||||
workflow.value.length > 0 &&
|
||||
matchingProjectWorkflows.value.length === 0,
|
||||
|
|
@ -138,6 +110,7 @@ const isMissing = computed(
|
|||
/** Target still exists but is archived or holds a node that can't run as a tool. */
|
||||
const isUnusable = computed(
|
||||
() =>
|
||||
workflowsLoaded.value &&
|
||||
!isLoadingWorkflows.value &&
|
||||
!isMissing.value &&
|
||||
workflow.value.length > 0 &&
|
||||
|
|
@ -152,6 +125,7 @@ const isAmbiguous = computed(
|
|||
/** Target works in preview, but the published agent cannot call it until it is published. */
|
||||
const isUnpublished = computed(
|
||||
() =>
|
||||
workflowsLoaded.value &&
|
||||
!isLoadingWorkflows.value &&
|
||||
!isUnusable.value &&
|
||||
targetWorkflow.value?.activeVersionId === null,
|
||||
|
|
@ -182,87 +156,34 @@ const selectedOptionId = computed(
|
|||
|
||||
const declaredInputFields = computed(() => listWorkflowToolInputFields(targetWorkflow.value));
|
||||
|
||||
function fieldBinding(fieldName: string): AgentJsonWorkflowToolInputField {
|
||||
return inputs.value[fieldName] ?? { mode: 'ai' };
|
||||
}
|
||||
const inputSchemaLoaded = computed(
|
||||
() =>
|
||||
workflowsLoaded.value &&
|
||||
!isLoadingWorkflows.value &&
|
||||
Array.isArray(targetWorkflow.value?.nodes),
|
||||
);
|
||||
const hasInputErrors = computed(
|
||||
() => inputSchemaLoaded.value && declaredInputFields.value.length > 0 && !inputsValid.value,
|
||||
);
|
||||
|
||||
function fieldMode(fieldName: string): 'ai' | 'fixed' {
|
||||
return fieldBinding(fieldName).mode;
|
||||
}
|
||||
watch(
|
||||
[name, description, workflow, hasInputErrors],
|
||||
([nameValue, descriptionValue, workflowValue]) => {
|
||||
emit(
|
||||
'update:valid',
|
||||
nameValue.trim().length > 0 &&
|
||||
descriptionValue.trim().length > 0 &&
|
||||
workflowValue.trim().length > 0 &&
|
||||
!hasInputErrors.value,
|
||||
);
|
||||
emit('update:node-name', nameValue);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function fieldType(fieldName: string): string | undefined {
|
||||
return declaredInputFields.value.find((field) => field.name === fieldName)?.type;
|
||||
}
|
||||
|
||||
function fieldFixedValue(fieldName: string): string {
|
||||
const binding = fieldBinding(fieldName);
|
||||
if (binding.mode !== 'fixed') return '';
|
||||
return formatWorkflowToolFixedValue(binding.value);
|
||||
}
|
||||
|
||||
// The trigger's declared field type only matters once the user types a fixed
|
||||
// value, so it surfaces as the input placeholder instead of a label.
|
||||
const FIXED_VALUE_PLACEHOLDER_KEYS: Record<string, BaseTextKey> = {
|
||||
string: 'agents.toolConfig.workflow.inputs.value.placeholder.string',
|
||||
number: 'agents.toolConfig.workflow.inputs.value.placeholder.number',
|
||||
boolean: 'agents.toolConfig.workflow.inputs.value.placeholder.boolean',
|
||||
array: 'agents.toolConfig.workflow.inputs.value.placeholder.array',
|
||||
object: 'agents.toolConfig.workflow.inputs.value.placeholder.object',
|
||||
};
|
||||
|
||||
function fieldFixedValuePlaceholder(fieldName: string): string {
|
||||
return i18n.baseText(
|
||||
FIXED_VALUE_PLACEHOLDER_KEYS[fieldType(fieldName) ?? ''] ??
|
||||
'agents.toolConfig.workflow.inputs.value.placeholder',
|
||||
);
|
||||
}
|
||||
|
||||
// Raw text being typed per field. While editing, the input shows this
|
||||
// uncoerced text so fractional numbers (and in-progress JSON) survive
|
||||
// each keystroke. On blur the text is parsed and committed to `inputs`.
|
||||
const fieldInputText = ref<Record<string, string>>({});
|
||||
|
||||
function fieldInputDisplay(fieldName: string): string {
|
||||
return fieldInputText.value[fieldName] ?? fieldFixedValue(fieldName);
|
||||
}
|
||||
|
||||
function handleFieldInput(fieldName: string, value: string | number) {
|
||||
fieldInputText.value = { ...fieldInputText.value, [fieldName]: String(value) };
|
||||
}
|
||||
|
||||
function commitFieldFixedValue(fieldName: string) {
|
||||
const raw = fieldInputText.value[fieldName];
|
||||
if (raw === undefined) return;
|
||||
setFieldFixedValue(fieldName, raw);
|
||||
const next = { ...fieldInputText.value };
|
||||
delete next[fieldName];
|
||||
fieldInputText.value = next;
|
||||
}
|
||||
|
||||
function setFieldMode(fieldName: string, nextMode: string) {
|
||||
if (nextMode !== 'ai' && nextMode !== 'fixed') return;
|
||||
// Discard any in-progress text when the mode changes.
|
||||
const nextText = { ...fieldInputText.value };
|
||||
delete nextText[fieldName];
|
||||
fieldInputText.value = nextText;
|
||||
if (nextMode === 'ai') {
|
||||
const next = { ...inputs.value };
|
||||
delete next[fieldName];
|
||||
inputs.value = next;
|
||||
return;
|
||||
}
|
||||
setFieldFixedValue(fieldName, fieldFixedValue(fieldName));
|
||||
}
|
||||
|
||||
function setFieldFixedValue(fieldName: string, value: string | number) {
|
||||
inputs.value = {
|
||||
...inputs.value,
|
||||
[fieldName]: {
|
||||
mode: 'fixed',
|
||||
value: parseWorkflowToolFixedValue(String(value), fieldType(fieldName)),
|
||||
},
|
||||
};
|
||||
}
|
||||
watch([() => props.submitCount, hasInputErrors], ([submitCount, hasErrors]) => {
|
||||
if (submitCount && hasErrors) advancedExpanded.value = true;
|
||||
});
|
||||
|
||||
function handleChangeName(newName: string) {
|
||||
name.value = newName;
|
||||
|
|
@ -327,16 +248,13 @@ function getWorkflowId() {
|
|||
}
|
||||
|
||||
function getInputs(): WorkflowToolRef['inputs'] {
|
||||
// Commit any in-progress text so the value isn't lost when the user
|
||||
// confirms the modal without blurring the field first.
|
||||
for (const fieldName of Object.keys(fieldInputText.value)) {
|
||||
commitFieldFixedValue(fieldName);
|
||||
}
|
||||
if (Object.keys(inputs.value).length === 0) return undefined;
|
||||
const bindings = toRaw(inputs.value);
|
||||
if (Object.keys(bindings).length === 0) return undefined;
|
||||
if (!inputSchemaLoaded.value) return bindings;
|
||||
// Drop bindings for fields that no longer exist on the selected workflow.
|
||||
const allowed = new Set(declaredInputFields.value.map((field) => field.name));
|
||||
const pruned: NonNullable<WorkflowToolRef['inputs']> = {};
|
||||
for (const [key, binding] of Object.entries(inputs.value)) {
|
||||
for (const [key, binding] of Object.entries(bindings)) {
|
||||
if (allowed.has(key)) pruned[key] = binding;
|
||||
}
|
||||
return Object.keys(pruned).length > 0 ? pruned : undefined;
|
||||
|
|
@ -513,76 +431,77 @@ defineExpose({
|
|||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="declaredInputFields.length > 0"
|
||||
:class="$style.field"
|
||||
data-test-id="agent-workflow-tool-inputs"
|
||||
>
|
||||
<label :class="$style.label">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs') }}
|
||||
</label>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs.hint') }}
|
||||
</N8nText>
|
||||
<div :class="$style.inputFields">
|
||||
<div
|
||||
v-for="field in declaredInputFields"
|
||||
:key="field.name"
|
||||
:class="$style.field"
|
||||
:data-test-id="`agent-workflow-tool-input-${field.name}`"
|
||||
>
|
||||
<N8nText size="small" :bold="true" :class="$style.inputName">{{ field.name }}</N8nText>
|
||||
<div :class="$style.controlRow">
|
||||
<N8nSelect
|
||||
:model-value="fieldMode(field.name)"
|
||||
:class="fieldMode(field.name) === 'fixed' ? $style.inputMode : $style.controlInput"
|
||||
:data-test-id="`agent-workflow-tool-input-mode-${field.name}`"
|
||||
@update:model-value="setFieldMode(field.name, $event)"
|
||||
>
|
||||
<N8nOption
|
||||
value="ai"
|
||||
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.ai')"
|
||||
/>
|
||||
<N8nOption
|
||||
value="fixed"
|
||||
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.fixed')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nInput
|
||||
v-if="fieldMode(field.name) === 'fixed'"
|
||||
:model-value="fieldInputDisplay(field.name)"
|
||||
:class="$style.controlInput"
|
||||
:placeholder="fieldFixedValuePlaceholder(field.name)"
|
||||
:data-test-id="`agent-workflow-tool-input-value-${field.name}`"
|
||||
@update:model-value="handleFieldInput(field.name, $event)"
|
||||
@blur="commitFieldFixedValue(field.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.toggleRow">
|
||||
<div :class="$style.toggleText">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="allOutputs"
|
||||
data-test-id="agent-workflow-tool-all-outputs"
|
||||
@update:model-value="allOutputs = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<slot name="commonSettings" />
|
||||
|
||||
<div :class="$style.field">
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.advancedTrigger"
|
||||
:aria-expanded="advancedExpanded"
|
||||
aria-controls="workflow-tool-advanced"
|
||||
data-test-id="agent-workflow-tool-advanced"
|
||||
@click="advancedExpanded = !advancedExpanded"
|
||||
>
|
||||
<N8nText size="small" bold>{{
|
||||
i18n.baseText('agents.toolConfig.workflow.advanced')
|
||||
}}</N8nText>
|
||||
<N8nIcon :icon="advancedExpanded ? 'chevron-up' : 'chevron-down'" size="small" />
|
||||
</button>
|
||||
<div id="workflow-tool-advanced" v-show="advancedExpanded" :class="$style.advancedContent">
|
||||
<div :class="$style.toggleRow">
|
||||
<div :class="$style.toggleText">
|
||||
<N8nText size="small" :bold="true">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.allOutputs.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="allOutputs"
|
||||
data-test-id="agent-workflow-tool-all-outputs"
|
||||
@update:model-value="allOutputs = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nText size="small" bold>{{
|
||||
i18n.baseText('agents.toolConfig.workflow.inputs')
|
||||
}}</N8nText>
|
||||
<N8nText v-if="isLoadingWorkflows" size="small" color="text-light" role="status">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs.loading') }}
|
||||
</N8nText>
|
||||
<template v-else-if="!workflowsLoaded">
|
||||
<N8nText size="small" color="danger" role="alert">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs.loadError') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:label="i18n.baseText('generic.retry')"
|
||||
@click="reloadWorkflows"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="inputSchemaLoaded">
|
||||
<N8nText v-if="!declaredInputFields.length" size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs.empty') }}
|
||||
</N8nText>
|
||||
<WorkflowToolInputs
|
||||
v-else
|
||||
:key="targetWorkflowId"
|
||||
v-model="inputs"
|
||||
:fields="declaredInputFields"
|
||||
:tool-name="name"
|
||||
:submitted="(submitCount ?? 0) > 0"
|
||||
@update:valid="inputsValid = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '@n8n/design-system/css/mixins/_focus.scss' as focus;
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -659,18 +578,24 @@ defineExpose({
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.inputFields {
|
||||
.advancedTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing--3xs) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
@include focus.focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.advancedContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
padding-top: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.inputName {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.inputMode {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, provide, ref, toRaw, useId, watch } from 'vue';
|
||||
import { N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { AgentJsonWorkflowToolInputField } from '@n8n/api-types';
|
||||
import { WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE, type INodeProperties } from 'n8n-workflow';
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import { ExpressionLocalResolveContextSymbol, WorkflowDocumentStoreKey } from '@/app/constants';
|
||||
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
|
||||
import {
|
||||
createWorkflowDocumentId,
|
||||
disposeWorkflowDocumentStore,
|
||||
useWorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import {
|
||||
disposeWorkflowExecutionStateStore,
|
||||
useWorkflowExecutionStateStore,
|
||||
} from '@/app/stores/workflowExecutionState.store';
|
||||
import { disposeNDVStore, useNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
import ParameterInputFull from '@/features/ndv/parameters/components/ParameterInputFull.vue';
|
||||
import {
|
||||
formatWorkflowToolFixedValue,
|
||||
parseWorkflowToolFixedValue,
|
||||
type WorkflowToolInputFieldDef,
|
||||
} from '../utils/workflowToolInputFields';
|
||||
import type { WorkflowToolRef } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
fields: WorkflowToolInputFieldDef[];
|
||||
toolName: string;
|
||||
submitted: boolean;
|
||||
}>();
|
||||
const inputs = defineModel<NonNullable<WorkflowToolRef['inputs']>>({ required: true });
|
||||
const emit = defineEmits<{ 'update:valid': [valid: boolean] }>();
|
||||
const i18n = useI18n();
|
||||
const id = useId();
|
||||
const rawValues = ref<Record<string, string>>({});
|
||||
|
||||
const documentId = createWorkflowDocumentId(`workflow-tool-inputs-${id}`);
|
||||
const documentStore = useWorkflowDocumentStore(documentId);
|
||||
const ndvStore = useNDVStore(documentId);
|
||||
const executionStore = useWorkflowExecutionStateStore(documentId);
|
||||
provide(
|
||||
WorkflowDocumentStoreKey,
|
||||
computed(() => documentStore),
|
||||
);
|
||||
provide(
|
||||
ExpressionLocalResolveContextSymbol,
|
||||
computed<ExpressionLocalResolveContext>(() => ({
|
||||
localResolve: true,
|
||||
nodeName: props.toolName,
|
||||
additionalKeys: {},
|
||||
})),
|
||||
);
|
||||
watch(
|
||||
() => props.toolName,
|
||||
(name) => {
|
||||
documentStore.setNodes([
|
||||
{
|
||||
id,
|
||||
name,
|
||||
type: WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
typeVersion: 2.2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
]);
|
||||
ndvStore.setActiveNodeName(name, 'other');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
disposeNDVStore(ndvStore);
|
||||
disposeWorkflowExecutionStateStore(executionStore);
|
||||
disposeWorkflowDocumentStore(documentStore);
|
||||
});
|
||||
|
||||
function bindingFor(name: string): AgentJsonWorkflowToolInputField {
|
||||
const bindings = toRaw(inputs.value);
|
||||
return Object.hasOwn(bindings, name) ? bindings[name] : { mode: 'ai' };
|
||||
}
|
||||
|
||||
function descriptionFor(name: string): string {
|
||||
const binding = bindingFor(name);
|
||||
return binding.mode === 'ai' ? (binding.description ?? '') : '';
|
||||
}
|
||||
|
||||
function parameterFor(field: WorkflowToolInputFieldDef): INodeProperties {
|
||||
let type: INodeProperties['type'] = 'string';
|
||||
if (field.type === 'number' || field.type === 'boolean') type = field.type;
|
||||
if (['array', 'object', 'any'].includes(field.type ?? '')) type = 'json';
|
||||
return {
|
||||
displayName: `${field.name} (${field.type ?? 'string'})`,
|
||||
name: field.name,
|
||||
type,
|
||||
default: '',
|
||||
typeOptions: { rows: 3 },
|
||||
};
|
||||
}
|
||||
|
||||
function displayValue(field: WorkflowToolInputFieldDef) {
|
||||
const binding = bindingFor(field.name);
|
||||
if (binding.mode === 'ai') return '';
|
||||
if (binding.mode === 'expression') return binding.value;
|
||||
if (field.type === 'boolean') return binding.value === true;
|
||||
const raw = toRaw(rawValues.value);
|
||||
if (Object.hasOwn(raw, field.name)) return raw[field.name];
|
||||
if (field.type === 'any') return JSON.stringify(binding.value);
|
||||
return formatWorkflowToolFixedValue(binding.value);
|
||||
}
|
||||
|
||||
function setMode(field: WorkflowToolInputFieldDef, mode: AgentJsonWorkflowToolInputField['mode']) {
|
||||
let next = { ...toRaw(inputs.value) };
|
||||
delete rawValues.value[field.name];
|
||||
if (mode === 'ai') {
|
||||
delete next[field.name];
|
||||
} else if (mode === 'expression') {
|
||||
next = { ...next, [field.name]: { mode, value: '=' } };
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
[field.name]: {
|
||||
mode,
|
||||
value: field.type === 'boolean' ? false : parseWorkflowToolFixedValue('', field.type),
|
||||
},
|
||||
};
|
||||
}
|
||||
inputs.value = next;
|
||||
}
|
||||
|
||||
function updateValue(field: WorkflowToolInputFieldDef, update: IUpdateInformation) {
|
||||
const raw = formatWorkflowToolFixedValue(update.value);
|
||||
const mode = bindingFor(field.name).mode;
|
||||
if (mode === 'ai') return;
|
||||
if (mode === 'fixed') rawValues.value = { ...toRaw(rawValues.value), [field.name]: raw };
|
||||
const binding: AgentJsonWorkflowToolInputField =
|
||||
mode === 'expression'
|
||||
? { mode, value: raw }
|
||||
: { mode, value: parseWorkflowToolFixedValue(raw, field.type) };
|
||||
inputs.value = { ...toRaw(inputs.value), [field.name]: binding };
|
||||
}
|
||||
|
||||
function updateDescription(name: string, description: string) {
|
||||
let next = { ...toRaw(inputs.value) };
|
||||
if (description.trim()) next = { ...next, [name]: { mode: 'ai', description } };
|
||||
else delete next[name];
|
||||
inputs.value = next;
|
||||
}
|
||||
|
||||
function inputError(field: WorkflowToolInputFieldDef): string | undefined {
|
||||
const binding = bindingFor(field.name);
|
||||
if (binding.mode === 'ai') return undefined;
|
||||
if (binding.mode === 'expression') {
|
||||
return binding.value.slice(1).trim()
|
||||
? undefined
|
||||
: i18n.baseText('agents.toolConfig.workflow.inputs.expression.empty');
|
||||
}
|
||||
const value = parseWorkflowToolFixedValue(
|
||||
formatWorkflowToolFixedValue(binding.value),
|
||||
field.type,
|
||||
);
|
||||
if (value === null) return undefined;
|
||||
let valid = true;
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
valid = typeof value === 'number' && Number.isFinite(value);
|
||||
break;
|
||||
case 'boolean':
|
||||
valid = typeof value === 'boolean';
|
||||
break;
|
||||
case 'array':
|
||||
valid = Array.isArray(value);
|
||||
break;
|
||||
case 'object':
|
||||
valid = typeof value === 'object' && !Array.isArray(value);
|
||||
break;
|
||||
case 'any':
|
||||
try {
|
||||
JSON.parse(String(displayValue(field)));
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
return valid
|
||||
? undefined
|
||||
: i18n.baseText('agents.toolConfig.workflow.inputs.value.invalid', {
|
||||
interpolate: { type: field.type ?? 'string' },
|
||||
});
|
||||
}
|
||||
|
||||
const fieldsWithErrors = computed(() => props.fields.filter((field) => inputError(field)));
|
||||
watch(fieldsWithErrors, (fields) => emit('update:valid', fields.length === 0), { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.fields" data-test-id="agent-workflow-tool-inputs">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.name"
|
||||
:class="$style.field"
|
||||
:data-test-id="`agent-workflow-tool-input-${encodeURIComponent(field.name)}`"
|
||||
role="group"
|
||||
:aria-label="field.name"
|
||||
>
|
||||
<ParameterInputFull
|
||||
:parameter="parameterFor(field)"
|
||||
:value="displayValue(field)"
|
||||
:input-mode="bindingFor(field.name).mode"
|
||||
:path="`inputs.${field.name}`"
|
||||
:rows="3"
|
||||
:display-options="true"
|
||||
:options-overrides="{ hideFocusPanelButton: true }"
|
||||
:external-issues="submitted && inputError(field) ? [inputError(field)!] : []"
|
||||
@update="updateValue(field, $event)"
|
||||
@text-input="updateValue(field, $event)"
|
||||
@update:input-mode="setMode(field, $event)"
|
||||
/>
|
||||
<template v-if="bindingFor(field.name).mode === 'ai'">
|
||||
<label :for="`${id}-${index}-description`" :class="$style.descriptionLabel">
|
||||
{{ i18n.baseText('agents.toolConfig.workflow.inputs.description') }}
|
||||
</label>
|
||||
<N8nInput
|
||||
:id="`${id}-${index}-description`"
|
||||
:model-value="descriptionFor(field.name)"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="i18n.baseText('agents.toolConfig.workflow.inputs.description.placeholder')"
|
||||
@update:model-value="updateDescription(field.name, String($event))"
|
||||
/>
|
||||
</template>
|
||||
<N8nText v-if="submitted && inputError(field)" size="xsmall" color="danger" role="alert">
|
||||
{{ inputError(field) }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--md);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.descriptionLabel {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -13,7 +13,8 @@ import {
|
|||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { FocusScope } from 'reka-ui';
|
||||
import { computed, nextTick, useSlots, useTemplateRef } from 'vue';
|
||||
import { computed, nextTick, provide, ref, useSlots, useTemplateRef } from 'vue';
|
||||
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -62,12 +63,15 @@ const emit = defineEmits<{
|
|||
const slots = useSlots();
|
||||
const i18n = useI18n();
|
||||
const body = useTemplateRef<HTMLElement>('body');
|
||||
const parameterDialogs = ref(new Set<string>());
|
||||
provide(ParameterInputModalContextKey, { openDialogs: parameterDialogs, appendTo: 'body' });
|
||||
const trapFocus = computed(() => props.trapFocus && parameterDialogs.value.size === 0);
|
||||
const hasFooter = computed(
|
||||
() =>
|
||||
props.showFooter ??
|
||||
Boolean(slots.footer || slots.footerLeft || slots.footerBeforeCancel || slots.footerActions),
|
||||
);
|
||||
const dismissalBlocked = computed(() => props.busy || !props.trapFocus);
|
||||
const dismissalBlocked = computed(() => props.busy || !trapFocus.value);
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open && dismissalBlocked.value) return;
|
||||
|
|
@ -83,7 +87,9 @@ function onBack() {
|
|||
}
|
||||
|
||||
function onEscapeKeyDown(event: KeyboardEvent) {
|
||||
if (dismissalBlocked.value) event.preventDefault();
|
||||
const isOutsideDialog =
|
||||
event.target instanceof Node && !body.value?.closest('[role="dialog"]')?.contains(event.target);
|
||||
if (dismissalBlocked.value || isOutsideDialog) event.preventDefault();
|
||||
}
|
||||
|
||||
function onInteractOutside(event: Event) {
|
||||
|
|
@ -108,8 +114,10 @@ function onOpenAutoFocus(event: Event) {
|
|||
:open="props.open"
|
||||
:size="props.size"
|
||||
:stacked="props.stacked"
|
||||
:trap-focus="props.trapFocus"
|
||||
:disable-outside-pointer-events="props.disableOutsidePointerEvents"
|
||||
:trap-focus="trapFocus"
|
||||
:disable-outside-pointer-events="
|
||||
props.disableOutsidePointerEvents && parameterDialogs.size === 0
|
||||
"
|
||||
:show-close-button="false"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
@interact-outside="onInteractOutside"
|
||||
|
|
@ -178,12 +186,7 @@ function onOpenAutoFocus(event: Event) {
|
|||
</N8nText>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<FocusScope
|
||||
v-if="!props.trapFocus"
|
||||
as-child
|
||||
@mount-auto-focus.prevent
|
||||
@unmount-auto-focus.prevent
|
||||
>
|
||||
<FocusScope v-if="!trapFocus" as-child @mount-auto-focus.prevent @unmount-auto-focus.prevent>
|
||||
<span hidden aria-hidden="true" />
|
||||
</FocusScope>
|
||||
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function useAgentToolCatalog() {
|
|||
}),
|
||||
);
|
||||
|
||||
async function loadWorkflows(projectId?: string): Promise<void> {
|
||||
async function loadWorkflows(projectId?: string): Promise<boolean> {
|
||||
try {
|
||||
// Fetch all project workflows (not just those with a supported trigger)
|
||||
// so unsupported ones can be shown greyed-out with a reason in the picker.
|
||||
|
|
@ -150,8 +150,10 @@ export function useAgentToolCatalog() {
|
|||
'updatedAt',
|
||||
],
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[useAgentToolCatalog] failed to load workflows for project', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { deepCopy } from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
|
||||
/** Copy JSON config without removing input names or reading Vue proxy methods. */
|
||||
export function copyAgentConfig(config: AgentJsonConfig): AgentJsonConfig {
|
||||
return jsonParse<AgentJsonConfig>(JSON.stringify(config, (_key, value: unknown) => toRaw(value)));
|
||||
}
|
||||
|
||||
export function tryParseConfig(text: string): { ok: true; value: AgentJsonConfig } | { ok: false } {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(text) as AgentJsonConfig };
|
||||
|
|
@ -41,7 +47,7 @@ export function getSlice(cfg: AgentJsonConfig | null, path: string | null | unde
|
|||
|
||||
/** Non-mutating write — returns a new config with `slice` placed at `path`. */
|
||||
export function setSlice(cfg: AgentJsonConfig, path: string, slice: unknown): AgentJsonConfig {
|
||||
const next = deepCopy(cfg);
|
||||
const next = copyAgentConfig(cfg);
|
||||
const parts = splitPath(path);
|
||||
if (parts.length === 0) return slice as AgentJsonConfig;
|
||||
let cur: unknown = next;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import { useKeybindings } from '@/app/composables/useKeybindings';
|
|||
import KeyboardShortcutTooltip from '@/app/components/KeyboardShortcutTooltip.vue';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { AGENT_EXTERNAL_UPDATE_NOTICE_DURATION, TIME } from '@/app/constants/durations';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { copyAgentConfig } from '../utils/agentSectionEditor.utils';
|
||||
import {
|
||||
getAgent,
|
||||
createAgent,
|
||||
|
|
@ -764,7 +764,7 @@ watch(
|
|||
config,
|
||||
(c) => {
|
||||
if (c) {
|
||||
localConfig.value = deepCopy(c);
|
||||
localConfig.value = copyAgentConfig(c);
|
||||
syncAgentIdentityFromConfig(c);
|
||||
}
|
||||
},
|
||||
|
|
@ -1753,7 +1753,7 @@ function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>, meta?: { source:
|
|||
// session memory disabled. Normalize on save so legacy configs are
|
||||
// corrected the next time the user makes a real edit, without mutating
|
||||
// config during component mount.
|
||||
config: normalizeAgentMemoryConfig(deepCopy(localConfig.value)),
|
||||
config: normalizeAgentMemoryConfig(copyAgentConfig(localConfig.value)),
|
||||
revision: configEditRevision,
|
||||
baseConfigHash: configHash.value,
|
||||
});
|
||||
|
|
@ -1801,13 +1801,13 @@ const appliedSkills = caps.appliedSkills;
|
|||
function replaceConfigAndScheduleSave(nextConfig: AgentJsonConfig) {
|
||||
markConfigDraftEdited();
|
||||
invalidateConfigValidation();
|
||||
localConfig.value = deepCopy(nextConfig);
|
||||
localConfig.value = copyAgentConfig(nextConfig);
|
||||
syncAgentIdentityFromConfig(localConfig.value);
|
||||
configAutosave.scheduleAutosave({
|
||||
projectId: projectId.value,
|
||||
agentId: agentId.value,
|
||||
type: 'config',
|
||||
config: normalizeAgentMemoryConfig(deepCopy(localConfig.value)),
|
||||
config: normalizeAgentMemoryConfig(copyAgentConfig(localConfig.value)),
|
||||
revision: configEditRevision,
|
||||
baseConfigHash: configHash.value,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import ExpressionEditorModalInput from './ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
||||
import { computed, ref, toRaw, watch } from 'vue';
|
||||
import { computed, inject, ref, toRaw, watch } from 'vue';
|
||||
import Close from 'virtual:icons/mdi/close';
|
||||
|
||||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
|
|
@ -22,6 +22,7 @@ import DraggableTarget from '@/app/components/DraggableTarget.vue';
|
|||
import { dropInExpressionEditor } from '@/features/shared/editors/plugins/codemirror/dragAndDrop';
|
||||
|
||||
import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
|
||||
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
|
||||
import { ElDialog } from 'element-plus';
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
import { useStyles } from '@n8n/composables/useStyles';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
const DEFAULT_LEFT_SIDEBAR_WIDTH = 360;
|
||||
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
|
||||
|
||||
type Props = {
|
||||
parameter: INodeProperties;
|
||||
|
|
@ -166,7 +168,8 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
|
|||
<template>
|
||||
<ElDialog
|
||||
width="calc(100% - var(--spacing--3xl))"
|
||||
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to-body="parameterModalContext?.appendTo === 'body'"
|
||||
:class="$style.modal"
|
||||
:model-value="dialogVisible"
|
||||
:before-close="closeDialog"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { computed, defineComponent, h, nextTick, reactive, shallowRef } from 'vue';
|
||||
import { computed, defineComponent, h, nextTick, reactive, ref, shallowRef } from 'vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import {
|
||||
ToolConfigCredentialSelectedKey,
|
||||
ParameterInputModalContextKey,
|
||||
WorkflowDocumentStoreKey,
|
||||
WorkflowIdKey,
|
||||
} from '@/app/constants/injectionKeys';
|
||||
|
|
@ -1006,6 +1007,33 @@ describe('ParameterInput.vue', () => {
|
|||
await waitFor(() => expect(expressionEditorInput).toHaveFocus());
|
||||
});
|
||||
|
||||
it('hosts the expanded expression editor and clears its focus state on close or unmount', async () => {
|
||||
const openDialogs = ref(new Set<string>());
|
||||
const view = renderComponent({
|
||||
global: {
|
||||
provide: { [ParameterInputModalContextKey]: { openDialogs, appendTo: 'body' } },
|
||||
},
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: createTestNodeProperties({ name: 'name', type: 'string' }),
|
||||
modelValue: '={{ $json.name }}',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(view.getByTestId('expander'));
|
||||
const expandedInput = await view.findByTestId('expression-modal-input');
|
||||
expect(view.container.contains(expandedInput)).toBe(false);
|
||||
expect(expandedInput.closest('#app-modals')).toBeNull();
|
||||
expect(openDialogs.value.size).toBe(1);
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Close this dialog' }));
|
||||
await waitFor(() => expect(openDialogs.value.size).toBe(0));
|
||||
|
||||
await fireEvent.click(view.getByTestId('expander'));
|
||||
expect(openDialogs.value.size).toBe(1);
|
||||
view.unmount();
|
||||
expect(openDialogs.value.size).toBe(0);
|
||||
});
|
||||
|
||||
describe('when not in focus', () => {
|
||||
test('should not focus after changing to expression ', async () => {
|
||||
const { rerender, getByRole, getByTestId } = renderComponent({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
useId,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { computedAsync, useDebounceFn, useElementSize } from '@vueuse/core';
|
||||
|
||||
import get from 'lodash/get';
|
||||
import truncate from 'lodash/truncate';
|
||||
import { CompactParameterHintsKey } from '@/app/constants/injectionKeys';
|
||||
import {
|
||||
CompactParameterHintsKey,
|
||||
ParameterInputModalContextKey,
|
||||
} from '@/app/constants/injectionKeys';
|
||||
|
||||
import type { INodeUpdatePropertiesInformation, IUpdateInformation, InputSize } from '@/Interface';
|
||||
import type {
|
||||
|
|
@ -199,6 +212,8 @@ const { isEnabled: isCollectionOverhaulEnabled } = useCollectionOverhaul();
|
|||
|
||||
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
|
||||
const onToolConfigCredentialSelected = inject(ToolConfigCredentialSelectedKey, undefined);
|
||||
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
|
||||
const parameterEditorId = useId();
|
||||
|
||||
const inputField = ref<InstanceType<typeof N8nInput | typeof N8nSelect> | HTMLElement>();
|
||||
const wrapper = ref<HTMLDivElement>();
|
||||
|
|
@ -210,6 +225,14 @@ const remoteParameterOptions = ref<INodePropertyOptions[]>([]);
|
|||
const remoteParameterOptionsLoading = ref(false);
|
||||
const remoteParameterOptionsLoadingIssues = ref<string | null>(null);
|
||||
const textEditDialogVisible = ref(false);
|
||||
watch(
|
||||
() =>
|
||||
codeEditDialogVisible.value || expressionEditDialogVisible.value || textEditDialogVisible.value,
|
||||
(open) => {
|
||||
if (open) parameterModalContext?.openDialogs.value.add(parameterEditorId);
|
||||
else parameterModalContext?.openDialogs.value.delete(parameterEditorId);
|
||||
},
|
||||
);
|
||||
const editDialogClosing = ref(false);
|
||||
const tempValue = ref('');
|
||||
const jsonValidationError = ref<string | null>(null);
|
||||
|
|
@ -1421,6 +1444,7 @@ defineExpose({
|
|||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
parameterModalContext?.openDialogs.value.delete(parameterEditorId);
|
||||
valueChangedDebounced.flush();
|
||||
onUpdateTextInputDebounced.flush();
|
||||
props.eventBus.off('optionSelected', optionSelected);
|
||||
|
|
@ -1682,7 +1706,8 @@ onUpdated(async () => {
|
|||
width="calc(100% - var(--spacing--3xl))"
|
||||
:class="$style.modal"
|
||||
:model-value="codeEditDialogVisible"
|
||||
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to-body="parameterModalContext?.appendTo === 'body'"
|
||||
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
|
||||
.nodeText(ndvStore?.activeNode?.type)
|
||||
.inputLabelDisplayName(parameter, path)}`"
|
||||
|
|
|
|||
|
|
@ -117,6 +117,49 @@ describe('ParameterInputFull.vue', () => {
|
|||
expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps explicit input modes separate from the parameter value', async () => {
|
||||
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: { AI: ['Tools'] },
|
||||
},
|
||||
});
|
||||
const {
|
||||
findByDisplayValue,
|
||||
getByRole,
|
||||
getByText,
|
||||
getByTestId,
|
||||
queryByTestId,
|
||||
emitted,
|
||||
rerender,
|
||||
} = renderComponent({
|
||||
props: { inputMode: 'fixed', value: '=literal', displayOptions: true },
|
||||
});
|
||||
|
||||
expect(await findByDisplayValue('=literal')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByText('Expression'));
|
||||
expect(emitted()['update:inputMode']).toEqual([['expression']]);
|
||||
|
||||
await fireEvent.click(getByTestId('from-ai-override-button'));
|
||||
expect(emitted()['update:inputMode']).toEqual([['expression'], ['ai']]);
|
||||
expect(emitted().update).toBeUndefined();
|
||||
|
||||
await rerender({ inputMode: 'ai' });
|
||||
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
|
||||
expect(queryByTestId('selectable-list-selectable-description')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByRole('button', { name: 'Edit value' }));
|
||||
expect(emitted()['update:inputMode']).toEqual([['expression'], ['ai'], ['fixed']]);
|
||||
expect(emitted().update).toBeUndefined();
|
||||
|
||||
await rerender({
|
||||
inputMode: undefined,
|
||||
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
|
||||
});
|
||||
expect(getByTestId('selectable-list-selectable-description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer a model override when disabled', () => {
|
||||
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
|
||||
codex: {
|
||||
|
|
@ -156,7 +199,7 @@ describe('ParameterInputFull.vue', () => {
|
|||
subcategories: { AI: ['Tools'] },
|
||||
},
|
||||
});
|
||||
const { queryByTestId, getByTestId } = renderComponent({
|
||||
const { queryByTestId, getByTestId, getByRole, emitted } = renderComponent({
|
||||
props: {
|
||||
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
|
||||
disableFromAi: true,
|
||||
|
|
@ -164,25 +207,30 @@ describe('ParameterInputFull.vue', () => {
|
|||
});
|
||||
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
|
||||
expect(queryByTestId('override-button')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByRole('button', { name: 'Edit value' }));
|
||||
expect(emitted().update).toEqual([[{ node: 'myParam', name: 'myParam', value: '' }]]);
|
||||
});
|
||||
|
||||
it('shows external validation issues in the parameter row', () => {
|
||||
it('keeps validation issues visible in read-only parameters', () => {
|
||||
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: { AI: ['Tools'] },
|
||||
},
|
||||
});
|
||||
const { getByTestId } = renderComponent({
|
||||
const { getByTestId, queryByRole } = renderComponent({
|
||||
props: {
|
||||
value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
|
||||
disableFromAi: true,
|
||||
isReadOnly: true,
|
||||
externalIssues: ["The model can't set the URL. Enter a fixed URL."],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
|
||||
expect(getByTestId('parameter-issues')).toBeInTheDocument();
|
||||
expect(queryByRole('button', { name: 'Edit value' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an existing fromAI override for static options parameters', async () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ type Props = {
|
|||
optionsOverrides?: ParameterOptionsOverrides;
|
||||
externalIssues?: string[];
|
||||
disableFromAi?: boolean;
|
||||
inputMode?: 'ai' | 'fixed' | 'expression';
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -79,6 +80,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
const emit = defineEmits<{
|
||||
blur: [];
|
||||
update: [value: IUpdateInformation];
|
||||
textInput: [value: IUpdateInformation];
|
||||
'update:inputMode': [mode: NonNullable<Props['inputMode']>];
|
||||
hover: [hovered: boolean];
|
||||
drop: [value: string];
|
||||
}>();
|
||||
|
|
@ -118,15 +121,22 @@ const { capabilities: inputCapabilities } = useParameterInputContribution(
|
|||
|
||||
const canCreateContentOverride = computed(() => {
|
||||
// An input that owns the from-AI override handles it separately
|
||||
if (props.disableFromAi || !activeNode.value || inputCapabilities.value.ownsFromAiOverride)
|
||||
return false;
|
||||
if (props.disableFromAi || inputCapabilities.value.ownsFromAiOverride) return false;
|
||||
if (props.inputMode !== undefined) return true;
|
||||
if (!activeNode.value) return false;
|
||||
|
||||
return canBeContentOverride(props, activeNode.value);
|
||||
});
|
||||
|
||||
const isContentOverride = computed(
|
||||
() => fromAIOverride.value !== null && !!isFromAIOverrideValue(props.value?.toString() ?? ''),
|
||||
);
|
||||
const isContentOverride = computed(() => {
|
||||
if (props.inputMode !== undefined) return props.inputMode === 'ai';
|
||||
return fromAIOverride.value !== null && !!isFromAIOverrideValue(props.value?.toString() ?? '');
|
||||
});
|
||||
|
||||
const inputParameter = computed(() => {
|
||||
if (props.inputMode !== 'fixed') return props.parameter;
|
||||
return { ...props.parameter, noDataExpression: true };
|
||||
});
|
||||
|
||||
const hint = computed(() =>
|
||||
i18n.nodeText(activeNode.value?.type).hint(props.parameter, props.path),
|
||||
|
|
@ -140,16 +150,23 @@ const isResourceLocator = computed(
|
|||
);
|
||||
const isDropDisabled = computed(
|
||||
() =>
|
||||
props.parameter.noDataExpression ||
|
||||
inputParameter.value.noDataExpression ||
|
||||
isContentOverride.value ||
|
||||
props.isReadOnly ||
|
||||
inputCapabilities.value.disableDrop ||
|
||||
isExpression.value,
|
||||
);
|
||||
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
|
||||
const isExpression = computed(() => {
|
||||
if (props.inputMode !== undefined) return props.inputMode === 'expression';
|
||||
return isValueExpression(props.parameter, props.value);
|
||||
});
|
||||
|
||||
const useInlineSwitchLayout = computed(
|
||||
() =>
|
||||
props.parameter.type === 'boolean' && isCollectionOverhaulEnabled.value && !isExpression.value,
|
||||
props.parameter.type === 'boolean' &&
|
||||
isCollectionOverhaulEnabled.value &&
|
||||
!isExpression.value &&
|
||||
!isContentOverride.value,
|
||||
);
|
||||
|
||||
const parameterTooltipText = computed(() =>
|
||||
|
|
@ -176,7 +193,7 @@ const showExpressionSelector = computed(() => {
|
|||
|
||||
function onFocus() {
|
||||
focused.value = true;
|
||||
if (!props.parameter.noDataExpression) {
|
||||
if (!inputParameter.value.noDataExpression) {
|
||||
ndvStore.value.setMappableNDVInputFocus(props.parameter.displayName);
|
||||
}
|
||||
ndvStore.value.setFocusedInputPath(props.path ?? '');
|
||||
|
|
@ -185,7 +202,7 @@ function onFocus() {
|
|||
function onBlur() {
|
||||
focused.value = false;
|
||||
if (
|
||||
!props.parameter.noDataExpression &&
|
||||
!inputParameter.value.noDataExpression &&
|
||||
ndvStore.value.focusedMappableInput === props.parameter.displayName
|
||||
) {
|
||||
ndvStore.value.setMappableNDVInputFocus('');
|
||||
|
|
@ -207,6 +224,16 @@ function onWrapperMouseLeave() {
|
|||
}
|
||||
|
||||
function optionSelected(command: string) {
|
||||
if (props.inputMode !== undefined) {
|
||||
if (command === 'addExpression') {
|
||||
emit('update:inputMode', 'expression');
|
||||
return;
|
||||
}
|
||||
if (command === 'removeExpression' || command === 'resetValue') {
|
||||
emit('update:inputMode', 'fixed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isContentOverride.value && command === 'resetValue') {
|
||||
removeOverride(true);
|
||||
}
|
||||
|
|
@ -218,6 +245,8 @@ function valueChanged(parameterData: IUpdateInformation) {
|
|||
}
|
||||
|
||||
function onTextInput(parameterData: IUpdateInformation) {
|
||||
emit('textInput', parameterData);
|
||||
if (props.inputMode !== undefined) return;
|
||||
if (isValueExpression(props.parameter, parameterData.value)) {
|
||||
eventBus.value.emit('optionSelected', 'addExpression');
|
||||
}
|
||||
|
|
@ -327,6 +356,10 @@ const isSingleLineInput: ComputedRef<boolean> = computed(
|
|||
);
|
||||
|
||||
function applyOverride() {
|
||||
if (props.inputMode !== undefined) {
|
||||
emit('update:inputMode', 'ai');
|
||||
return;
|
||||
}
|
||||
if (!fromAIOverride.value) return;
|
||||
|
||||
telemetry.track('User turned on fromAI override', {
|
||||
|
|
@ -343,22 +376,26 @@ function applyOverride() {
|
|||
}
|
||||
|
||||
function removeOverride(clearField = false) {
|
||||
if (!fromAIOverride.value) return;
|
||||
if (props.inputMode !== undefined) {
|
||||
emit('update:inputMode', 'fixed');
|
||||
} else {
|
||||
if (!fromAIOverride.value) return;
|
||||
|
||||
// In chat hub tool configuration context, always reset to default since expressions aren't supported
|
||||
const shouldClear = clearField || isChatHubToolContext;
|
||||
// Reset standalone node tools to their default because expressions are disabled.
|
||||
const shouldClear = clearField || isChatHubToolContext;
|
||||
|
||||
telemetry.track('User turned off fromAI override', {
|
||||
nodeType: activeNode.value?.type,
|
||||
parameter: props.path,
|
||||
});
|
||||
valueChanged({
|
||||
node: activeNode.value?.name,
|
||||
name: props.path,
|
||||
value: shouldClear
|
||||
? props.parameter.default
|
||||
: buildValueFromOverride(fromAIOverride.value, props, false),
|
||||
});
|
||||
telemetry.track('User turned off fromAI override', {
|
||||
nodeType: activeNode.value?.type,
|
||||
parameter: props.path,
|
||||
});
|
||||
valueChanged({
|
||||
node: activeNode.value?.name,
|
||||
name: props.path,
|
||||
value: shouldClear
|
||||
? props.parameter.default
|
||||
: buildValueFromOverride(fromAIOverride.value, props, false),
|
||||
});
|
||||
}
|
||||
void setTimeout(async () => {
|
||||
await parameterInputWrapper.value?.focusInput();
|
||||
parameterInputWrapper.value?.selectInput();
|
||||
|
|
@ -384,7 +421,7 @@ function removeOverride(clearField = false) {
|
|||
<template #default="{ droppable, activeDrop }">
|
||||
<ParameterInputWrapper
|
||||
ref="parameterInputWrapper"
|
||||
:parameter="parameter"
|
||||
:parameter="inputParameter"
|
||||
:model-value="value"
|
||||
:path="path"
|
||||
:is-read-only="isReadOnly"
|
||||
|
|
@ -421,6 +458,7 @@ function removeOverride(clearField = false) {
|
|||
v-if="displayOptions"
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:input-mode="inputMode"
|
||||
:is-read-only="isReadOnly"
|
||||
:show-options="displayOptions"
|
||||
:show-expression-selector="showExpressionSelector"
|
||||
|
|
@ -469,6 +507,7 @@ function removeOverride(clearField = false) {
|
|||
<ParameterOptions
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:input-mode="inputMode"
|
||||
:is-read-only="isReadOnly"
|
||||
:show-options="displayOptions"
|
||||
:show-expression-selector="showExpressionSelector"
|
||||
|
|
@ -489,7 +528,7 @@ function removeOverride(clearField = false) {
|
|||
>
|
||||
<template #default="{ droppable, activeDrop }">
|
||||
<FromAiOverrideField
|
||||
v-if="fromAIOverride && isContentOverride"
|
||||
v-if="isContentOverride"
|
||||
:is-read-only="isReadOnly"
|
||||
:issues="externalIssues"
|
||||
@close="removeOverride(!canCreateContentOverride)"
|
||||
|
|
@ -497,7 +536,7 @@ function removeOverride(clearField = false) {
|
|||
<div v-else>
|
||||
<ParameterInputWrapper
|
||||
ref="parameterInputWrapper"
|
||||
:parameter="parameter"
|
||||
:parameter="inputParameter"
|
||||
:model-value="value"
|
||||
:path="path"
|
||||
:is-read-only="isReadOnly"
|
||||
|
|
@ -545,6 +584,7 @@ function removeOverride(clearField = false) {
|
|||
v-if="optionsPosition === 'bottom'"
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:input-mode="inputMode"
|
||||
:is-read-only="isReadOnly"
|
||||
:show-options="displayOptions"
|
||||
:show-expression-selector="showExpressionSelector"
|
||||
|
|
@ -566,6 +606,7 @@ function removeOverride(clearField = false) {
|
|||
v-if="displayOptions && optionsPosition === 'top-absolute'"
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:input-mode="inputMode"
|
||||
:is-read-only="isReadOnly"
|
||||
:show-options="displayOptions"
|
||||
:show-expression-selector="showExpressionSelector"
|
||||
|
|
@ -578,7 +619,9 @@ function removeOverride(clearField = false) {
|
|||
/>
|
||||
</div>
|
||||
<ParameterOverrideSelectableList
|
||||
v-if="canCreateContentOverride && isContentOverride && fromAIOverride"
|
||||
v-if="
|
||||
inputMode === undefined && canCreateContentOverride && isContentOverride && fromAIOverride
|
||||
"
|
||||
v-model="fromAIOverride"
|
||||
:parameter="parameter"
|
||||
:path="path"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import AiStarsIcon from '@/app/components/AiStarsIcon.vue';
|
||||
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
const i18n = useI18n();
|
||||
|
||||
withDefaults(
|
||||
|
|
@ -24,13 +23,16 @@ const emit = defineEmits<{
|
|||
<div>{{ i18n.baseText('parameterOverride.applyOverrideButtonTooltip') }}</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
<N8nIconButton
|
||||
icon="sparkles"
|
||||
variant="ghost"
|
||||
:size="size"
|
||||
icon-size="small"
|
||||
:class="[$style.overrideButton, $style[position], $style[size]]"
|
||||
:aria-label="i18n.baseText('parameterOverride.applyOverrideButtonTooltip')"
|
||||
data-test-id="from-ai-override-button"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<AiStarsIcon size="large" />
|
||||
</div>
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,91 +1,88 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nIcon, N8nIconButton, N8nTag, N8nText } from '@n8n/design-system';
|
||||
import { i18n } from '@n8n/i18n';
|
||||
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import AiStarsIcon from '@/app/components/AiStarsIcon.vue';
|
||||
import ParameterIssues from '../ParameterIssues.vue';
|
||||
|
||||
defineProps<{
|
||||
isReadOnly?: boolean;
|
||||
issues?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.contentOverrideContainer" data-test-id="fromAI-override-field">
|
||||
<div :class="[$style.iconStars, $style.noCornersRight]">
|
||||
<AiStarsIcon />
|
||||
</div>
|
||||
<div :class="$style.overrideInput">
|
||||
<N8nText
|
||||
v-n8n-html="i18n.baseText('parameterOverride.overridePanelText')"
|
||||
color="text-dark"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="issues?.length || !isReadOnly" :class="$style.actions">
|
||||
<ParameterIssues v-if="issues?.length" :issues="issues" />
|
||||
<div v-if="!isReadOnly" :class="$style.overrideCloseButton" @click="emit('close')">
|
||||
<N8nIcon icon="x" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.field" data-test-id="fromAI-override-field">
|
||||
<N8nTag
|
||||
:text="i18n.baseText('parameterOverride.overridePanelText')"
|
||||
:clickable="false"
|
||||
size="md"
|
||||
:class="$style.tag"
|
||||
>
|
||||
<template #tag>
|
||||
<N8nIcon icon="sparkles" size="small" :class="$style.icon" />
|
||||
<N8nText size="small" compact :class="$style.label">
|
||||
{{ i18n.baseText('parameterOverride.overridePanelText') }}
|
||||
</N8nText>
|
||||
<N8nIconButton
|
||||
v-if="!isReadOnly"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
:class="$style.remove"
|
||||
:aria-label="i18n.baseText('parameterOverride.editValue')"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</template>
|
||||
</N8nTag>
|
||||
<ParameterIssues v-if="issues?.length" :class="$style.issues" :issues="issues" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.iconStars {
|
||||
align-self: center;
|
||||
padding-left: 8px;
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
border: 0;
|
||||
color: var(--color--foreground--shade-2);
|
||||
background-color: unset;
|
||||
}
|
||||
@use '@n8n/design-system/css/mixins/_input.scss' as input;
|
||||
|
||||
.noCornersRight {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.overrideInput {
|
||||
height: 30px;
|
||||
align-content: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overrideCloseButton {
|
||||
border: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
padding: 0 var(--spacing--2xs);
|
||||
align-self: stretch;
|
||||
.field {
|
||||
@include input.size-variables('small');
|
||||
@include input.theme-variables;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color--text);
|
||||
}
|
||||
width: 100%;
|
||||
height: var(--input--height);
|
||||
box-sizing: border-box;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--5xs);
|
||||
border-radius: var(--input--radius);
|
||||
background-color: var(--input--color--background);
|
||||
box-shadow: inset var(--input--border--shadow);
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.tag {
|
||||
--tag--min-width: 0;
|
||||
--tag--max-width: 100%;
|
||||
gap: var(--spacing--3xs);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.issues {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.contentOverrideContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
gap: var(--spacing--4xs);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--foreground);
|
||||
}
|
||||
|
||||
.remove {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-color--subtler);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface Props {
|
|||
parameter: INodeProperties;
|
||||
isReadOnly: boolean;
|
||||
value: NodeParameterValueType;
|
||||
inputMode?: 'ai' | 'fixed' | 'expression';
|
||||
showOptions?: boolean;
|
||||
showExpressionSelector?: boolean;
|
||||
showFocusPanel?: boolean;
|
||||
|
|
@ -60,7 +61,10 @@ const ndvStore = injectNDVStoreIfProvided();
|
|||
|
||||
const activeNode = computed(() => ndvStore.value?.activeNode ?? null);
|
||||
const isDefault = computed(() => props.parameter.default === props.value);
|
||||
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
|
||||
const isValueAnExpression = computed(() => {
|
||||
if (props.inputMode !== undefined) return props.inputMode === 'expression';
|
||||
return isValueExpression(props.parameter, props.value);
|
||||
});
|
||||
const editor = computed(() => getParameterTypeOption(props.parameter, 'editor'));
|
||||
const isChatHubToolContext = inject(ChatHubToolContextKey, false);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, nextTick, computed } from 'vue';
|
||||
import { ref, watch, onMounted, nextTick, computed, inject } from 'vue';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
|
||||
import { ParameterInputModalContextKey } from '@/app/constants/injectionKeys';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ const inputField = ref<HTMLInputElement | null>(null);
|
|||
const tempValue = ref('');
|
||||
|
||||
const ndvStore = injectNDVStore();
|
||||
const parameterModalContext = inject(ParameterInputModalContextKey, undefined);
|
||||
const i18n = useI18n();
|
||||
|
||||
const activeNode = computed(() => ndvStore.value.activeNode);
|
||||
|
|
@ -70,7 +72,8 @@ const closeDialog = () => {
|
|||
<div v-if="dialogVisible">
|
||||
<ElDialog
|
||||
:model-value="dialogVisible"
|
||||
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to="parameterModalContext?.appendTo ?? `#${APP_MODALS_ELEMENT_ID}`"
|
||||
:append-to-body="parameterModalContext?.appendTo === 'body'"
|
||||
width="80%"
|
||||
:title="`${i18n.baseText('textEdit.edit')} ${i18n
|
||||
.nodeText(activeNode?.type)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as Helpers from './helpers';
|
||||
import type { NodeParameterValueType } from '../src';
|
||||
import type { INodeExecutionData, NodeParameterValueType } from '../src';
|
||||
import { Workflow } from '../src/workflow';
|
||||
|
||||
describe('WorkflowExpression', () => {
|
||||
|
|
@ -30,8 +30,18 @@ describe('WorkflowExpression', () => {
|
|||
await expression.releaseIsolate();
|
||||
});
|
||||
|
||||
const evaluate = (value: NodeParameterValueType) =>
|
||||
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', {});
|
||||
const evaluate = (value: NodeParameterValueType, inputData: INodeExecutionData[] = []) =>
|
||||
expression.getParameterValue(value, null, 0, 0, 'node', inputData, 'manual', {});
|
||||
|
||||
it('preserves own input fields when copying expression results', () => {
|
||||
const input = {
|
||||
constructor: 'Constructor value',
|
||||
toString: 'String value',
|
||||
hasOwnProperty: 'Own value',
|
||||
};
|
||||
|
||||
expect(evaluate('={{ { payload: $json } }}', [{ json: input }])).toEqual({ payload: input });
|
||||
});
|
||||
|
||||
it('should resolve $parameter["&key"] sibling reference within an object', () => {
|
||||
// n8n uses the `&`-prefixed syntax internally (e.g. in node parameter definitions)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user