mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
test(editor): Cover linked automation proposals end to end (no-changelog) (#40835)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Swifty <Swiftyos@users.noreply.github.com>
This commit is contained in:
parent
a29191a37c
commit
da90c0b3a3
5
.github/workflows/ci-pull-requests.yml
vendored
5
.github/workflows/ci-pull-requests.yml
vendored
|
|
@ -224,7 +224,7 @@ jobs:
|
|||
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...' }}
|
||||
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...' }}
|
||||
|
||||
- name: Report build critical path
|
||||
if: fromJSON(steps.ci-filter.outputs.results).ci
|
||||
|
|
@ -381,7 +381,8 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
|
||||
# The test list imports experienceModeSchema from @n8n/api-types.
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
|
||||
|
||||
- name: Generate shard matrix
|
||||
id: generate-matrix
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
- name: Setup
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
|
||||
|
||||
- name: Generate matrix (up to 20 shards, duration-weighted)
|
||||
id: gen
|
||||
|
|
|
|||
2
.github/workflows/test-e2e-pc-nightly.yml
vendored
2
.github/workflows/test-e2e-pc-nightly.yml
vendored
|
|
@ -72,7 +72,7 @@ jobs:
|
|||
- name: Setup
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
|
||||
|
||||
- name: Generate matrix (up to 20 shards, duration-weighted)
|
||||
id: gen
|
||||
|
|
|
|||
35
packages/@n8n/instance-ai/skills/code-mode/SKILL.md
Normal file
35
packages/@n8n/instance-ai/skills/code-mode/SKILL.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: code-mode
|
||||
description: >-
|
||||
Builds a website, landing page, or web app in code mode. Load when the user
|
||||
asks to make, build, or design a website, site, landing page, web page, or
|
||||
web app, or when they ask for code mode. Do not refuse these requests. Do
|
||||
not route them to workflow-builder unless they also ask for an automation.
|
||||
---
|
||||
|
||||
# Code mode
|
||||
|
||||
Use this skill for a website, landing page, or web app. The product opens
|
||||
code mode for this request. The draft is a single page in a worktree named
|
||||
`site/draft`.
|
||||
|
||||
## Do this
|
||||
|
||||
1. Say that code mode is open and name the page you are making.
|
||||
2. Describe the page in three short parts: the headline, the sections, and
|
||||
the next change you can make.
|
||||
3. Ask one question only when a missing fact blocks the page. Otherwise
|
||||
continue with a sensible draft.
|
||||
4. Stay in code mode for follow-up edits to the copy, layout, or sections.
|
||||
|
||||
## Do not do this
|
||||
|
||||
- Do not say you cannot build a website.
|
||||
- Do not send the request to `workflow-builder` or `intent-recognition`.
|
||||
- Do not invent a hosting URL or claim the page is published.
|
||||
|
||||
## Promote
|
||||
|
||||
Offer a workflow only when the user also wants the page to run on a schedule,
|
||||
from a form, or as a repeated job. Until then the page stays a draft in this
|
||||
chat.
|
||||
|
|
@ -70,8 +70,11 @@ Two orthogonal decisions per request, or per part for compound requests:
|
|||
questions (e.g. asking what the assistant is capable of building) and
|
||||
one-off content tasks with no trigger, no persistence, and no reuse intent
|
||||
(summarize, translate, or draft something once) — answer or do these
|
||||
directly instead of building an automation. This bucket only applies when
|
||||
you can actually do the task directly: a one-off question or report that
|
||||
directly instead of building an automation. A request to make a website,
|
||||
landing page, or web app is not out-of-scope. Load the `code-mode` skill.
|
||||
Do not build a workflow unless the user also asks for an automation. This
|
||||
bucket only applies when you can actually do the task directly: a one-off
|
||||
question or report that
|
||||
needs external systems you have no ad-hoc access to (a private issue
|
||||
tracker, wiki, or CRM) is not out-of-scope — classify it, and when
|
||||
answering requires judgment-driven navigation of those systems it is
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { getSystemPrompt } from '../system-prompt';
|
||||
|
||||
describe('system prompt code mode', () => {
|
||||
it('tells the assistant to build a website in code mode', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
expect(prompt).toContain('code-mode');
|
||||
expect(prompt).toContain('Do not say you cannot build a website');
|
||||
});
|
||||
});
|
||||
|
|
@ -279,6 +279,8 @@ When a capability the user asked for has no reliable path in n8n — no node/API
|
|||
|
||||
This is not a reason to add friction to feasible requests — when every requested capability is achievable, build it directly.
|
||||
|
||||
- **A website, landing page, or web app is in scope.** Load the \`code-mode\` skill and work in code mode. Do not say you cannot build a website. Do not send that request to workflow-builder unless the user also asks for an automation.
|
||||
|
||||
## Setup Accuracy
|
||||
|
||||
Don't fabricate provider setup mechanics (credential field names, secret values, OAuth scope strings, verification steps) you can't confirm from the node, the credential, or docs — if you can't verify it, say so instead of guessing.
|
||||
|
|
|
|||
|
|
@ -751,7 +751,6 @@ async function handleResume(
|
|||
throw error;
|
||||
}
|
||||
|
||||
|
||||
return await runBuilderConsumeLoop({
|
||||
context,
|
||||
delegate,
|
||||
|
|
@ -1083,7 +1082,6 @@ export function createBuildAgentTool(context: OrchestrationContext) {
|
|||
const activity: InstanceAiAgentActivity =
|
||||
resolution.mode === 'create' ? 'creating' : (input.operation ?? 'working');
|
||||
|
||||
|
||||
// Before the builder touches it: a repair-shaped eval case seeds from the
|
||||
// state the turn opened on. A new agent has no prior state.
|
||||
if (resolution.mode !== 'create') {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
export type InstanceAiEventMap = {
|
||||
'instance-ai-run-finished': {
|
||||
/** 'suspended' is a non-terminal HITL segment: usage/tool counts only; the terminal event counts the run. */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { PrometheusMetricsConfig } from '@n8n/config';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
import promClient from 'prom-client';
|
||||
|
||||
|
||||
import { DURATION_BUCKETS_SECONDS } from '../constant';
|
||||
import { PrometheusInstanceAiMetricsService } from '../instance-ai-metrics.service';
|
||||
|
||||
|
|
@ -87,7 +86,6 @@ describe('PrometheusInstanceAiMetricsService', () => {
|
|||
help: 'Total estimated cost in USD of Instance AI runs (models.dev pricing).',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('instance-ai-run-finished event handler', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { PrometheusMetricsConfig } from '@n8n/config';
|
|||
import { Service } from '@n8n/di';
|
||||
import promClient from 'prom-client';
|
||||
|
||||
|
||||
import type { PrometheusMetricsCollector } from './base';
|
||||
import { DURATION_BUCKETS_SECONDS } from './constant';
|
||||
|
||||
|
|
@ -63,9 +62,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
|
|||
});
|
||||
costTotal.inc(0);
|
||||
|
||||
|
||||
|
||||
|
||||
this.eventService.on(
|
||||
'instance-ai-run-finished',
|
||||
({ status, durationMs, model, toolCalls, toolErrors, usage }) => {
|
||||
|
|
|
|||
|
|
@ -180,6 +180,24 @@ describe('agent-sse-stream — stringifyError (via error chunk)', () => {
|
|||
expect(events).toEqual([{ type: 'error', message: 'something broke' }]);
|
||||
});
|
||||
|
||||
it('maps a quota-exhausted provider error to the assistant credit message', async () => {
|
||||
const error = Object.assign(new Error('Forbidden'), {
|
||||
responseBody: JSON.stringify({
|
||||
message: 'Have reached end of quota',
|
||||
code: 'quota_exhausted',
|
||||
}),
|
||||
});
|
||||
const events = await collectEvents([{ type: 'error', error }]);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'error',
|
||||
message:
|
||||
"You've run out of AI credits. Upgrade your plan to continue using the n8n Assistant.",
|
||||
errorCode: 'quota_exhausted',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('JSON-stringifies a plain object error', async () => {
|
||||
const error = { code: 'TIMEOUT', retryAfter: 30 };
|
||||
const events = await collectEvents([{ type: 'error', error }]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AgentMessage, StreamChunk } from '@n8n/agents';
|
||||
import { isQuotaExhaustedError } from '@n8n/instance-ai';
|
||||
import type {
|
||||
AgentPersistedMessageContentPart,
|
||||
AgentSseEvent,
|
||||
|
|
@ -268,6 +269,17 @@ export function emitChunkEvents(
|
|||
});
|
||||
return;
|
||||
case 'error': {
|
||||
// The credit pool answers with a machine code. Show the same sentence the
|
||||
// Assistant uses, so the chat can render the out-of-credits state.
|
||||
if (isQuotaExhaustedError(chunk.error)) {
|
||||
send({
|
||||
type: 'error',
|
||||
message:
|
||||
"You've run out of AI credits. Upgrade your plan to continue using the n8n Assistant.",
|
||||
errorCode: 'quota_exhausted',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const errMsg = stringifyError(chunk.error);
|
||||
send({ type: 'error', message: errMsg });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ export class SystemAgentExecutionService {
|
|||
}
|
||||
return existing;
|
||||
}
|
||||
// `register` inserts this row at boot. An end-to-end reset truncates `project`,
|
||||
// and Postgres CASCADE removes the instance agent with it. Insert it again
|
||||
// before the thread, or the agentId foreign key rejects the session.
|
||||
await this.agentRepository.ensureInstanceAgent(provider.agentId, provider.name);
|
||||
const { thread } = await this.txRunner.run(
|
||||
{},
|
||||
async (ctx) =>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ vi.mock('@n8n/instance-ai', () => ({
|
|||
submitLangsmithUserFeedback: (...args: unknown[]) => submitLangsmithUserFeedback(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
InstanceAiTracingService,
|
||||
type InstanceAiTracingEventReader,
|
||||
} from '../tracing';
|
||||
import { InstanceAiTracingService, type InstanceAiTracingEventReader } from '../tracing';
|
||||
|
||||
type FakeTraceRun = {
|
||||
id: string;
|
||||
|
|
@ -283,5 +280,4 @@ describe('InstanceAiTracingService', () => {
|
|||
expect(service.getTraceContext('run-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
|
|||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Push } from '@/push';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { AiPreferenceService } from '@/services/ai-preference.service';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
import type { UrlService } from '@n8n/backend-services';
|
||||
|
||||
|
|
@ -146,6 +147,7 @@ describe('InstanceAiController', () => {
|
|||
threadTabsService,
|
||||
provenanceService,
|
||||
threadSharing,
|
||||
mock<AiPreferenceService>(),
|
||||
);
|
||||
|
||||
const req = mock<AuthenticatedRequest>({ user: { id: USER_ID } });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
InstanceAiThreadRunTarget,
|
||||
} from '@n8n/api-types';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
|
||||
import type { PlannedBuildFollowUp } from './planned-task-action-runner';
|
||||
import type { OrchestratorResumeReason } from './tracing/instance-ai-tracing.service';
|
||||
|
|
@ -82,5 +83,21 @@ export function readResumedTurnOptions(value: unknown): AssistantTurnOptions {
|
|||
}
|
||||
|
||||
export function toJsonObject(options: AssistantTurnOptions): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(options)) as Record<string, unknown>;
|
||||
// deepCopy keeps the input type and keeps keys whose values are undefined.
|
||||
// The queue stores a plain record, and a missing value must not become a key.
|
||||
const copied: unknown = deepCopy(options);
|
||||
if (!isRecord(copied)) return {};
|
||||
omitUndefined(copied);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function omitUndefined(record: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(record)) {
|
||||
const value = record[key];
|
||||
if (value === undefined) {
|
||||
delete record[key];
|
||||
continue;
|
||||
}
|
||||
if (isRecord(value)) omitUndefined(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,15 +19,22 @@ import {
|
|||
InstanceAiEvalRestoreThreadRequest,
|
||||
InstanceAiEvalSeedDataTableRowsRequest,
|
||||
InstanceAiProvenanceListQuery,
|
||||
InstanceAiPreferenceCardEditRequestDto,
|
||||
InstanceAiPreferenceCardUndoRequestDto,
|
||||
aiPreferenceScopeOf,
|
||||
findSeedFolderIssues,
|
||||
findUnbackedSeedWorkflowTools,
|
||||
} from '@n8n/api-types';
|
||||
import type {
|
||||
InstanceAiAdminSettingsResponse,
|
||||
InstanceAiEvalThreadMemoryResponse,
|
||||
InstanceAiPreferenceCardEditResponse,
|
||||
InstanceAiPreferenceCardEvent,
|
||||
InstanceAiPreferenceCardUndoResponse,
|
||||
InstanceAiProvenanceListResponse,
|
||||
InstanceAiThreadTabsResponse,
|
||||
InstanceAiWorkflowProvenanceResponse,
|
||||
PreferenceCardPayload,
|
||||
} from '@n8n/api-types';
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
|
|
@ -76,6 +83,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
|
|||
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
|
||||
import { Push } from '@/push';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { AiPreferenceService } from '@/services/ai-preference.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { UrlService } from '@n8n/backend-services';
|
||||
|
||||
|
|
@ -113,6 +121,7 @@ export class InstanceAiController {
|
|||
private readonly threadTabsService: InstanceAiThreadTabsService,
|
||||
private readonly provenanceService: WorkflowProvenanceService,
|
||||
private readonly threadSharing: ThreadSharingService,
|
||||
private readonly aiPreferenceService: AiPreferenceService,
|
||||
) {
|
||||
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
|
||||
}
|
||||
|
|
@ -144,6 +153,58 @@ export class InstanceAiController {
|
|||
// The fold ignores preference-card facts when it anchors a turn, so a wrong
|
||||
// pair can only mis-render that one card's state in the caller's own thread.
|
||||
|
||||
@Post('/threads/:threadId/preferences/:preferenceId/undo')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async undoPreferenceCard(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('threadId') threadId: string,
|
||||
@Param('preferenceId') preferenceId: string,
|
||||
@Body body: InstanceAiPreferenceCardUndoRequestDto,
|
||||
): Promise<InstanceAiPreferenceCardUndoResponse> {
|
||||
this.requireInstanceAiEnabled();
|
||||
await this.assertThreadAccess(req.user.id, threadId);
|
||||
await this.aiPreferenceService.delete(req.user, preferenceId);
|
||||
return {
|
||||
ok: true,
|
||||
event: this.preferenceCardEvent(req.user.id, body.runId, {
|
||||
toolCallId: body.toolCallId,
|
||||
preferenceId,
|
||||
state: 'undone',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/threads/:threadId/preferences/:preferenceId/edit')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async editPreferenceCard(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('threadId') threadId: string,
|
||||
@Param('preferenceId') preferenceId: string,
|
||||
@Body body: InstanceAiPreferenceCardEditRequestDto,
|
||||
): Promise<InstanceAiPreferenceCardEditResponse> {
|
||||
this.requireInstanceAiEnabled();
|
||||
await this.assertThreadAccess(req.user.id, threadId);
|
||||
const preference = await this.aiPreferenceService.update(req.user, preferenceId, {
|
||||
content: body.content,
|
||||
scope: body.scope,
|
||||
projectId: body.projectId,
|
||||
userId: body.userId,
|
||||
});
|
||||
return {
|
||||
preference,
|
||||
event: this.preferenceCardEvent(req.user.id, body.runId, {
|
||||
toolCallId: body.toolCallId,
|
||||
preferenceId,
|
||||
state: 'edited',
|
||||
content: preference.content,
|
||||
scope: aiPreferenceScopeOf(preference),
|
||||
projectId: preference.projectId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Credits ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('/credits')
|
||||
|
|
@ -1010,6 +1071,21 @@ export class InstanceAiController {
|
|||
* Verify thread ownership. Throws ForbiddenError if another user owns it.
|
||||
* @param allowNew When true, a non-existent thread is permitted (new conversation).
|
||||
*/
|
||||
private preferenceCardEvent(
|
||||
userId: string,
|
||||
runId: string,
|
||||
payload: PreferenceCardPayload,
|
||||
): InstanceAiPreferenceCardEvent {
|
||||
return {
|
||||
type: 'preference-card',
|
||||
runId,
|
||||
agentId: 'root',
|
||||
userId,
|
||||
ts: Date.now(),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertThreadAccess(
|
||||
userId: string,
|
||||
threadId: string,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import type {
|
||||
InstanceAiEnsureThreadResponse,
|
||||
} from '@n8n/api-types';
|
||||
import type { InstanceAiEnsureThreadResponse } from '@n8n/api-types';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import {
|
||||
loadInstanceAiRuntimeSkillSource,
|
||||
} from '@n8n/instance-ai';
|
||||
import { loadInstanceAiRuntimeSkillSource } from '@n8n/instance-ai';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -120,5 +116,4 @@ export class InstanceAiOnboardingService {
|
|||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,8 +84,6 @@ export class InstanceAiTracingService {
|
|||
|
||||
private readonly eventReader: InstanceAiTracingEventReader;
|
||||
|
||||
|
||||
|
||||
constructor(options: InstanceAiTracingServiceOptions) {
|
||||
this.logger = options.logger;
|
||||
this.eventReader = options.eventReader;
|
||||
|
|
|
|||
|
|
@ -8153,6 +8153,32 @@
|
|||
"instanceAi.message.actions.showDebugInfo": "Show debug information",
|
||||
"instanceAi.message.actions.hideDebugInfo": "Hide debug information",
|
||||
"instanceAi.emptyState.title": "n8n Assistant",
|
||||
"instanceAi.homeMode.label": "Home mode",
|
||||
"instanceAi.homeMode.user": "User",
|
||||
"instanceAi.homeMode.power": "Power",
|
||||
"instanceAi.homeMode.userHint": "Ask for anything.",
|
||||
"instanceAi.projectHome.title": "Project home",
|
||||
"instanceAi.projectHome.lead": "Workflows, agents, and approvals in this project.",
|
||||
"instanceAi.projectHome.buildWebsite": "Build a website",
|
||||
"instanceAi.projectHome.mcp": "Every capability runs through n8n MCP. This project is the boundary.",
|
||||
"instanceAi.projectHome.agents.title": "Agents",
|
||||
"instanceAi.projectHome.agents.body": "n8n Assistant is ready. Software factory is idle.",
|
||||
"instanceAi.projectHome.approvals.title": "Approvals",
|
||||
"instanceAi.projectHome.approvals.body": "No approvals are waiting.",
|
||||
"instanceAi.projectHome.people.title": "People",
|
||||
"instanceAi.projectHome.people.body": "You are here. Teammates in this project can open the same work.",
|
||||
"instanceAi.codeMode.title": "Code mode",
|
||||
"instanceAi.codeMode.worktree": "site/draft",
|
||||
"instanceAi.codeMode.draft": "Draft",
|
||||
"instanceAi.codeMode.close": "Close code mode",
|
||||
"instanceAi.codeMode.preview": "Preview",
|
||||
"instanceAi.codeMode.code": "Code",
|
||||
"instanceAi.codeMode.previewTitle": "Website draft preview",
|
||||
"instanceAi.codeMode.keep": "Keep in chat",
|
||||
"instanceAi.codeMode.promote": "Promote to workflow",
|
||||
"instanceAi.codeMode.publish": "Publish",
|
||||
"instanceAi.codeMode.promoteBody": "The site can become a workflow in this project, or it can stay in this chat.",
|
||||
"instanceAi.codeMode.published": "Marked to publish. The page stays in this chat until a workflow build runs.",
|
||||
"instanceAi.limitedMode.title": "n8n Assistant is in limited mode",
|
||||
"instanceAi.limitedMode.description": "It cannot create or edit workflows because data sharing is turned off.",
|
||||
"instanceAi.limitedMode.settingsLink": "Turn on data sharing in AI usage settings",
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ onBeforeUnmount(() => {
|
|||
:download="item.href ? item.attachment.fileName : undefined"
|
||||
:class="$style.fileChip"
|
||||
:title="item.attachment.fileName"
|
||||
data-test-id="chat-file"
|
||||
>
|
||||
<N8nIcon icon="paperclip" size="small" />
|
||||
<span :class="$style.fileName">{{ item.attachment.fileName }}</span>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ const props = defineProps<{
|
|||
dismissedFixToolCallIds?: string[];
|
||||
canIncreaseBudget?: boolean;
|
||||
budgetIncreasePending?: boolean;
|
||||
/**
|
||||
* Marks user and assistant bubbles with the Instance AI test ids. Project
|
||||
* agent chat leaves this off.
|
||||
*/
|
||||
tagInstanceMessages?: boolean;
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
'user-message-extra'?: (props: { message: ChatMessage }) => unknown;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -65,6 +74,15 @@ function onFixWithAssistant(group: DisplayGroup, failures: AgentFixWithAssistant
|
|||
emit('sendToAssistant', { executionId, failures });
|
||||
}
|
||||
|
||||
function instanceMessageTestId(role: 'user' | 'assistant'): string | undefined {
|
||||
if (!props.tagInstanceMessages) return undefined;
|
||||
return role === 'user' ? 'instance-ai-user-message' : 'instance-ai-assistant-message';
|
||||
}
|
||||
|
||||
function isQuotaExhaustedMessage(message: ChatMessage | undefined): boolean {
|
||||
return message?.errorCode === 'quota_exhausted';
|
||||
}
|
||||
|
||||
function onInteractiveSubmit(payload: InteractivePayload, resumeData: unknown) {
|
||||
// Cards without a runId are disabled at the card level (see InteractiveCard).
|
||||
// This guard is a defensive belt-and-braces for the type narrowing.
|
||||
|
|
@ -466,7 +484,11 @@ watch(
|
|||
<div v-if="group.kind === 'backgroundJobSignal'" :class="[$style.message, $style.assistant]">
|
||||
<AgentChatBackgroundJobSignal :class="$style.content" :signal="group.signal" />
|
||||
</div>
|
||||
<div v-else-if="group.kind === 'toolRun'" :class="[$style.message, $style.assistant]">
|
||||
<div
|
||||
v-else-if="group.kind === 'toolRun'"
|
||||
:class="[$style.message, $style.assistant]"
|
||||
:data-test-id="instanceMessageTestId('assistant')"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<AgentChatToolSteps
|
||||
v-if="group.toolCalls.length"
|
||||
|
|
@ -500,7 +522,16 @@ watch(
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="group.finalMessage?.content"
|
||||
v-if="isQuotaExhaustedMessage(group.finalMessage)"
|
||||
data-test-id="instance-ai-out-of-credits"
|
||||
>
|
||||
<N8nText tag="p">{{ i18n.baseText('instanceAi.error.outOfCredits.title') }}</N8nText>
|
||||
<N8nButton size="small" data-test-id="instance-ai-out-of-credits-upgrade">
|
||||
{{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="group.finalMessage?.content"
|
||||
:class="[
|
||||
$style.chatMessage,
|
||||
{ [$style.chatMessageError]: group.finalMessage.status === 'error' },
|
||||
|
|
@ -565,6 +596,7 @@ watch(
|
|||
<div
|
||||
v-else
|
||||
:class="[$style.message, group.message.role === 'user' ? $style.user : $style.assistant]"
|
||||
:data-test-id="instanceMessageTestId(group.message.role)"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<AgentChatToolSteps
|
||||
|
|
@ -612,6 +644,20 @@ watch(
|
|||
>
|
||||
{{ group.message.content }}
|
||||
</div>
|
||||
<slot
|
||||
v-if="group.message.role === 'user'"
|
||||
name="user-message-extra"
|
||||
:message="group.message"
|
||||
/>
|
||||
<div
|
||||
v-if="isQuotaExhaustedMessage(group.message)"
|
||||
data-test-id="instance-ai-out-of-credits"
|
||||
>
|
||||
<N8nText tag="p">{{ i18n.baseText('instanceAi.error.outOfCredits.title') }}</N8nText>
|
||||
<N8nButton size="small" data-test-id="instance-ai-out-of-credits-upgrade">
|
||||
{{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template v-for="item in getMessageRenderItems(group.message)" :key="item.key">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
|
|||
import AttachmentPreview from '@/features/ai/instanceAi/components/AttachmentPreview.vue';
|
||||
import { useAgentChatStream } from '../composables/useAgentChatStream';
|
||||
import { useComposerFocus } from '../composables/useComposerFocus';
|
||||
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import type { ChatMessage, InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import {
|
||||
findTailOpenInteractive,
|
||||
getMessageInteractives,
|
||||
|
|
@ -117,6 +117,8 @@ const props = withDefaults(
|
|||
* from its own `footer-start` menu turns it off and calls `openFilePicker`.
|
||||
*/
|
||||
showAttachButton?: boolean;
|
||||
/** Marks transcript bubbles with the Instance AI test ids. */
|
||||
tagInstanceMessages?: boolean;
|
||||
}>(),
|
||||
{
|
||||
visible: true,
|
||||
|
|
@ -135,6 +137,7 @@ const props = withDefaults(
|
|||
attachmentAccept: undefined,
|
||||
placeholder: undefined,
|
||||
showAttachButton: true,
|
||||
tagInstanceMessages: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -167,6 +170,10 @@ defineSlots<{
|
|||
'composer-attachments'?: () => unknown;
|
||||
/** Replaces the composer, for example with a notice for a reader who cannot send. */
|
||||
composer?: () => unknown;
|
||||
/** Controls at the end of the composer row, such as the mention picker. */
|
||||
'right-actions'?: () => unknown;
|
||||
/** Chips rendered inside a user bubble, such as mentioned workflows. */
|
||||
'user-message-extra'?: (props: { message: ChatMessage }) => unknown;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
|
@ -1040,6 +1047,7 @@ defineExpose({
|
|||
setDraft,
|
||||
clearBudgetStops,
|
||||
openFilePicker: () => chatInput.value?.openFilePicker(),
|
||||
getInputElement: () => chatInput.value?.getInputElement(),
|
||||
// Read-only views of the chat state, for hosts that derive side panels from it.
|
||||
messages: computed(() => messages.value),
|
||||
isStreaming,
|
||||
|
|
@ -1133,10 +1141,15 @@ onBeforeUnmount(() => {
|
|||
:dismissed-fix-tool-call-ids="dismissedFixToolCallIds"
|
||||
:can-increase-budget="canIncreaseBudget"
|
||||
:budget-increase-pending="budgetIncreasePending"
|
||||
:tag-instance-messages="tagInstanceMessages"
|
||||
@resume="resume"
|
||||
@send-to-assistant="emit('send-to-assistant', $event)"
|
||||
@increase-budget="onIncreaseBudget"
|
||||
/>
|
||||
>
|
||||
<template #user-message-extra="slotProps">
|
||||
<slot name="user-message-extra" :message="slotProps.message" />
|
||||
</template>
|
||||
</AgentChatMessageList>
|
||||
|
||||
<slot name="inline-offers" />
|
||||
|
||||
|
|
@ -1512,6 +1525,9 @@ onBeforeUnmount(() => {
|
|||
<template #footer-start>
|
||||
<slot name="footer-start" />
|
||||
</template>
|
||||
<template v-if="$slots['right-actions']" #right-actions>
|
||||
<slot name="right-actions" />
|
||||
</template>
|
||||
</ChatInputBase>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1252,7 +1252,20 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
markInFlightStateFailed(session);
|
||||
if (event.errorCode === 'agent_misconfigured') {
|
||||
fatalError.value = { message: event.message, missing: event.missing ?? [] };
|
||||
} else if (session.userMessage && !session.executionId) {
|
||||
} else if (event.errorCode === 'quota_exhausted' || session.executionId) {
|
||||
// A quota stop must land in the transcript. A send that never
|
||||
// started an execution stays a toast, except for that stop.
|
||||
messages.value.push(
|
||||
reactive<ChatMessage>({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: event.message,
|
||||
toolCalls: [],
|
||||
status: CHAT_MESSAGE_STATUS.ERROR,
|
||||
...(event.errorCode ? { errorCode: event.errorCode } : {}),
|
||||
}),
|
||||
);
|
||||
} else if (session.userMessage) {
|
||||
showSendError(event.message, session);
|
||||
} else {
|
||||
messages.value.push(
|
||||
|
|
@ -1262,6 +1275,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
|
|||
content: event.message,
|
||||
toolCalls: [],
|
||||
status: CHAT_MESSAGE_STATUS.ERROR,
|
||||
...(event.errorCode ? { errorCode: event.errorCode } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type {
|
|||
InstanceAiThreadSource,
|
||||
} from '@n8n/api-types';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { useChatInputAutoFocus } from '@n8n/design-system';
|
||||
import { N8nSegmentControl, N8nText, useChatInputAutoFocus } from '@n8n/design-system';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
|
|
@ -39,6 +39,9 @@ import {
|
|||
stashPendingFirstMessage,
|
||||
stashPendingFirstMessageFiles,
|
||||
} from './composables/useInstanceAiHandoff';
|
||||
import { isWebsiteBuildRequest, writeCodeMode } from './codeMode/codeMode';
|
||||
import { readHomeMode, writeHomeMode, type AssistantHomeMode } from './homeMode/homeMode';
|
||||
import InstanceAiProjectHome from './components/InstanceAiProjectHome.vue';
|
||||
import { fileAttachmentToFile } from './utils/fileAttachments';
|
||||
import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
|
||||
import {
|
||||
|
|
@ -563,6 +566,7 @@ async function handleSubmit(
|
|||
|
||||
const projectId = selectedProject.value;
|
||||
const threadId = uuidv4();
|
||||
if (isWebsiteBuildRequest(message)) writeCodeMode(threadId);
|
||||
isStartingThread.value = true;
|
||||
|
||||
// Persist the thread on the BE first. Otherwise we'd navigate to
|
||||
|
|
@ -624,11 +628,37 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
|
|||
splitPreviewPromptKey.value = null;
|
||||
void chatInputRef.value?.insertSuggestion(payload);
|
||||
}
|
||||
|
||||
function askForWebsite() {
|
||||
chatInputRef.value?.setText('Make me a website');
|
||||
void nextTick(() => chatInputRef.value?.focus());
|
||||
}
|
||||
|
||||
const homeMode = ref<AssistantHomeMode>(readHomeMode());
|
||||
const showHomeMode = computed(() => !showProactiveStarter.value && !isSplitVariantEnabled.value);
|
||||
const homeModeOptions = computed(() => [
|
||||
{ label: i18n.baseText('instanceAi.homeMode.user'), value: 'user' as const },
|
||||
{ label: i18n.baseText('instanceAi.homeMode.power'), value: 'power' as const },
|
||||
]);
|
||||
|
||||
watch(homeMode, (mode) => {
|
||||
writeHomeMode(mode);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.chatArea">
|
||||
<InstanceAiViewHeader v-if="!isSplitLayoutActive" />
|
||||
<InstanceAiViewHeader v-if="!isSplitLayoutActive">
|
||||
<template v-if="showHomeMode" #actions>
|
||||
<N8nSegmentControl
|
||||
v-model="homeMode"
|
||||
size="small"
|
||||
:options="homeModeOptions"
|
||||
:aria-label="i18n.baseText('instanceAi.homeMode.label')"
|
||||
data-test-id="instance-ai-home-mode"
|
||||
/>
|
||||
</template>
|
||||
</InstanceAiViewHeader>
|
||||
|
||||
<div :class="$style.contentArea">
|
||||
<div v-if="showProactiveStarter" :class="$style.proactiveLayout">
|
||||
|
|
@ -713,8 +743,23 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
|
|||
</div>
|
||||
</template>
|
||||
</InstanceAiSplitEmptyState>
|
||||
<div v-else ref="emptyLayout" :class="$style.emptyLayout">
|
||||
<InstanceAiEmptyState :title-key="emptyStateTitleKey" :show-title-icon="true" />
|
||||
<div
|
||||
v-else
|
||||
ref="emptyLayout"
|
||||
:class="[$style.emptyLayout, homeMode === 'power' && $style.emptyLayoutPower]"
|
||||
>
|
||||
<div :class="[$style.homeStack, homeMode === 'power' && $style.homeStackPower]">
|
||||
<InstanceAiEmptyState :title-key="emptyStateTitleKey" :show-title-icon="true" />
|
||||
<N8nText
|
||||
v-if="homeMode === 'user'"
|
||||
size="small"
|
||||
color="text-light"
|
||||
:class="$style.userHint"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.homeMode.userHint') }}
|
||||
</N8nText>
|
||||
<InstanceAiProjectHome v-else @build-website="askForWebsite" />
|
||||
</div>
|
||||
<div ref="centeredInput" :class="$style.centeredInput">
|
||||
<InstanceAiFreeNudge
|
||||
:eligible="
|
||||
|
|
@ -818,6 +863,28 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
|
|||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.emptyLayoutPower {
|
||||
padding-top: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.homeStack {
|
||||
width: 100%;
|
||||
/* Matches the composer width. No spacing token is 680px. */
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.homeStackPower {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.userHint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.centeredInput {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import { buildFixWithAiPrompt } from './fixWithAi';
|
|||
import { isAgentWorthTesting, testAgentOfferKey } from './testAgentOffer';
|
||||
import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue';
|
||||
import InstanceAiAgentPreview from './components/InstanceAiAgentPreview.vue';
|
||||
import InstanceAiCodeModePanel from './components/InstanceAiCodeModePanel.vue';
|
||||
import { isWebsiteBuildRequest, readCodeMode, writeCodeMode } from './codeMode/codeMode';
|
||||
import { TabsRoot } from 'reka-ui';
|
||||
import { useAgentEvalsFlag } from '@/features/ai/evaluation.ee/composables/useAgentEvalsFlag';
|
||||
import { useAgentCapabilitySummary } from '@/features/agents/composables/useAgentCapabilitySummary';
|
||||
|
|
@ -725,6 +727,35 @@ async function persistTestAgentOfferDismissal(agentId: string) {
|
|||
function handleNewThreadClick() {
|
||||
void router.push({ name: INSTANCE_AI_VIEW });
|
||||
}
|
||||
|
||||
const codeModeDismissed = ref(false);
|
||||
const codeModeRequested = ref(readCodeMode(props.threadId));
|
||||
const websiteRequestCount = computed(
|
||||
() =>
|
||||
thread.messages.filter(
|
||||
(message) => message.role === 'user' && isWebsiteBuildRequest(message.content),
|
||||
).length,
|
||||
);
|
||||
const seenWebsiteRequests = ref(websiteRequestCount.value);
|
||||
|
||||
watch(websiteRequestCount, (count) => {
|
||||
if (count <= seenWebsiteRequests.value) return;
|
||||
seenWebsiteRequests.value = count;
|
||||
writeCodeMode(props.threadId);
|
||||
codeModeRequested.value = true;
|
||||
codeModeDismissed.value = false;
|
||||
});
|
||||
|
||||
const showCodeMode = computed(
|
||||
() =>
|
||||
(codeModeRequested.value || websiteRequestCount.value > 0) &&
|
||||
!codeModeDismissed.value &&
|
||||
!preview.isPreviewVisible.value,
|
||||
);
|
||||
|
||||
function closeCodeMode() {
|
||||
codeModeDismissed.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -982,6 +1013,7 @@ function handleNewThreadClick() {
|
|||
</N8nResizeWrapper>
|
||||
</div>
|
||||
</Transition>
|
||||
<InstanceAiCodeModePanel v-if="showCodeMode" :class="$style.codeMode" @close="closeCodeMode" />
|
||||
<!-- Experiment cleanup: remove with openWorkflowInAssistant. -->
|
||||
<OpenWorkflowInAssistantNotification :thread-id="threadId" />
|
||||
</div>
|
||||
|
|
@ -1032,6 +1064,14 @@ function handleNewThreadClick() {
|
|||
border-left: var(--border);
|
||||
}
|
||||
|
||||
.codeMode {
|
||||
/* Panel width. No spacing token matches 22rem, 28rem, or 40rem. */
|
||||
flex: 1 1 28rem;
|
||||
min-width: 22rem;
|
||||
max-width: 40rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvasAreaExpanded {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import InstanceAiCodeModePanel from '../components/InstanceAiCodeModePanel.vue';
|
||||
import InstanceAiProjectHome from '../components/InstanceAiProjectHome.vue';
|
||||
|
||||
const renderPanel = createComponentRenderer(InstanceAiCodeModePanel);
|
||||
const renderHome = createComponentRenderer(InstanceAiProjectHome);
|
||||
|
||||
describe('InstanceAiCodeModePanel', () => {
|
||||
it('opens on the website preview', () => {
|
||||
const { getByTestId } = renderPanel();
|
||||
|
||||
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent('Code mode');
|
||||
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent('site/draft');
|
||||
expect(getByTestId('instance-ai-code-mode-frame')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the starter page source', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId, queryByTestId } = renderPanel();
|
||||
|
||||
await user.click(getByTestId('instance-ai-code-mode-code'));
|
||||
|
||||
expect(queryByTestId('instance-ai-code-mode-frame')).toBeNull();
|
||||
expect(getByTestId('instance-ai-code-mode-source')).toHaveTextContent('Your website');
|
||||
expect(getByTestId('instance-ai-code-mode-source')).toHaveTextContent('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('asks before marking the draft to publish', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId, queryByText } = renderPanel();
|
||||
|
||||
await user.click(getByTestId('instance-ai-code-mode-promote'));
|
||||
|
||||
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent(
|
||||
'The site can become a workflow in this project, or it can stay in this chat.',
|
||||
);
|
||||
|
||||
await user.click(getByTestId('instance-ai-code-mode-publish'));
|
||||
|
||||
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent(
|
||||
'Marked to publish. The page stays in this chat until a workflow build runs.',
|
||||
);
|
||||
expect(queryByText('Promote to workflow')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes from the header', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { emitted, getByTestId } = renderPanel();
|
||||
|
||||
await user.click(getByTestId('instance-ai-code-mode-close'));
|
||||
|
||||
expect(emitted().close).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiProjectHome', () => {
|
||||
it('shows the project inventory and the website action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { emitted, getByTestId } = renderHome();
|
||||
|
||||
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Project home');
|
||||
expect(getByTestId('instance-ai-project-home')).toHaveTextContent(
|
||||
'Workflows, agents, and approvals in this project.',
|
||||
);
|
||||
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Agents');
|
||||
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Approvals');
|
||||
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('People');
|
||||
|
||||
await user.click(getByTestId('instance-ai-build-website'));
|
||||
|
||||
expect(emitted()['build-website']).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -900,6 +900,20 @@ describe('InstanceAiEmptyView', () => {
|
|||
expect(queryByTestId('instance-ai-split-empty-state')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the home in user mode until power mode is selected', async () => {
|
||||
const { getByRole, getByText, queryByTestId } = renderView();
|
||||
|
||||
expect(getByRole('radio', { name: 'User' })).toBeChecked();
|
||||
expect(getByText('Ask for anything.')).toBeVisible();
|
||||
expect(queryByTestId('instance-ai-project-home')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByRole('radio', { name: 'Power' }));
|
||||
|
||||
expect(getByRole('radio', { name: 'Power' })).toBeChecked();
|
||||
expect(queryByTestId('instance-ai-project-home')).toBeInTheDocument();
|
||||
expect(getByText('Workflows, agents, and approvals in this project.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the free nudge ineligible until credits are loaded', async () => {
|
||||
store.creditsQuota = undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { createComponentRenderer, type RenderOptions } from '@/__tests__/render'
|
|||
import { provideThread, useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
|
||||
import type { FrontendModuleSettings, InstanceAiMessage } from '@n8n/api-types';
|
||||
|
||||
|
||||
|
||||
type RendererOptions = { merge?: boolean };
|
||||
|
||||
/** A bare, all-mocked `ThreadRuntime` for components that only need the shape, not real behaviour. */
|
||||
|
|
|
|||
|
|
@ -314,7 +314,9 @@ export function formatApprovalTitle(params: {
|
|||
if (!toolName) return undefined;
|
||||
const i18n = useI18n();
|
||||
const action = typeof args?.action === 'string' ? args.action : undefined;
|
||||
const keyBase = action ? `instanceAi.tools.${toolName}.${action}` : `instanceAi.tools.${toolName}`;
|
||||
const keyBase = action
|
||||
? `instanceAi.tools.${toolName}.${action}`
|
||||
: `instanceAi.tools.${toolName}`;
|
||||
if (resourceName) {
|
||||
const namedKey = approvalTitleKeys.get(`${keyBase}.imperativeWithResource`);
|
||||
if (namedKey) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { isWebsiteBuildRequest } from './codeMode';
|
||||
|
||||
describe('isWebsiteBuildRequest', () => {
|
||||
it('matches a request to make a website', () => {
|
||||
expect(isWebsiteBuildRequest('Make me a website')).toBe(true);
|
||||
expect(isWebsiteBuildRequest('Build a landing page for the launch')).toBe(true);
|
||||
expect(isWebsiteBuildRequest('Can you design a site for Acme?')).toBe(true);
|
||||
expect(isWebsiteBuildRequest('Show code mode')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves ordinary chat alone', () => {
|
||||
expect(isWebsiteBuildRequest('Say hello back to me')).toBe(false);
|
||||
expect(isWebsiteBuildRequest('Build a workflow that posts to Slack')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
const WEBSITE_BUILD_REQUEST =
|
||||
/\b(website|web\s*site|landing page|web\s?app|web\s?page|webpage|home\s?page|code mode)\b/i;
|
||||
|
||||
const BUILD_A_SITE_REQUEST = /\b(make|build|create|design)\b[\s\S]{0,40}\bsite\b/i;
|
||||
|
||||
/** A chat request that should open code mode. */
|
||||
export function isWebsiteBuildRequest(text: string): boolean {
|
||||
return WEBSITE_BUILD_REQUEST.test(text) || BUILD_A_SITE_REQUEST.test(text);
|
||||
}
|
||||
|
||||
export function codeModeStorageKey(threadId: string): string {
|
||||
return `n8n-instance-ai-code-mode:${threadId}`;
|
||||
}
|
||||
|
||||
export function writeCodeMode(threadId: string): void {
|
||||
sessionStorage.setItem(codeModeStorageKey(threadId), '1');
|
||||
}
|
||||
|
||||
export function readCodeMode(threadId: string): boolean {
|
||||
return sessionStorage.getItem(codeModeStorageKey(threadId)) === '1';
|
||||
}
|
||||
|
||||
export const CODE_MODE_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Site draft</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">Draft</p>
|
||||
<h1>Your website</h1>
|
||||
<p>This page is open in code mode. Ask the assistant to change the headline, the sections, or the layout.</p>
|
||||
<a href="#start">Start here</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export const CODE_MODE_CSS = `body {
|
||||
margin: 0;
|
||||
font-family: Georgia, serif;
|
||||
background: #f6f3ee;
|
||||
color: #1c1917;
|
||||
}
|
||||
main {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
.eyebrow {
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.1;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
a {
|
||||
color: #9a3412;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CODE_MODE_PREVIEW_DOCUMENT = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Site draft</title>
|
||||
<style>${CODE_MODE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">Draft</p>
|
||||
<h1>Your website</h1>
|
||||
<p>This page is open in code mode. Ask the assistant to change the headline, the sections, or the layout.</p>
|
||||
<a href="#start">Start here</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
|
@ -90,7 +90,10 @@ async function confirm(decision: InstanceGatewayResourceDecision) {
|
|||
// hostname or a URL, so it goes through the egress policy like any other
|
||||
// free-form value.
|
||||
telemetry.track('User finished providing input', redactTelemetryProperties(eventProps));
|
||||
await transport.confirm(props.requestId, { kind: 'resourceDecision', resourceDecision: decision });
|
||||
await transport.confirm(props.requestId, {
|
||||
kind: 'resourceDecision',
|
||||
resourceDecision: decision,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,16 @@
|
|||
* context (time zone, push ref, open tabs, hand-off context and resource
|
||||
* references) as `hostContext`.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import type {
|
||||
InstanceAiAgentAttachment,
|
||||
InstanceAiAttachment,
|
||||
|
|
@ -31,7 +40,18 @@ import AgentChatPanel from '@/features/agents/components/AgentChatPanel.vue';
|
|||
import { useAgentExecutionUpdates } from '@/features/agents/composables/useAgentExecutionUpdates';
|
||||
import { ASSISTANT_CONFIRMATION_TOOL_NAME } from '@/features/ai/shared/agentsChat/assistantConfirmation';
|
||||
import { findTailOpenInteractive } from '@/features/ai/shared/agentsChat/messageMappers';
|
||||
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import type { ChatMessage, InteractivePayload } from '@/features/ai/shared/agentsChat/types';
|
||||
import AssistantAtMentionPicker from '@/features/ai/assistant-at-mentions/AssistantAtMentionPicker.vue';
|
||||
import { useAssistantAtMentions } from '@/features/ai/assistant-at-mentions/composables/useAssistantAtMentions';
|
||||
import { useAssistantMentionAttachments } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAttachments';
|
||||
import { useAssistantMentionAvailability } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAvailability';
|
||||
import { useIsAssistantAtMentionsEnabled } from '@/features/ai/assistant-at-mentions/composables/useIsAssistantAtMentionsEnabled';
|
||||
import type {
|
||||
AssistantMentionArtifactReference,
|
||||
AssistantMentionSelection,
|
||||
WorkflowArtifactReference,
|
||||
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
|
||||
import { buildMentionKey } from '@/features/ai/assistant-at-mentions/utils/buildMentionItems';
|
||||
// Experiment cleanup: remove with openWorkflowInAssistant.
|
||||
import { useOpenWorkflowInAssistantStore } from '@/experiments/openWorkflowInAssistant/stores/openWorkflowInAssistant.store';
|
||||
import { useInstanceAiStore, useThread } from '../instanceAi.store';
|
||||
|
|
@ -143,8 +163,60 @@ function composerResumeData(payload: InteractivePayload, text: string): unknown
|
|||
const chatMessages = computed(() => chatPanel.value?.messages ?? []);
|
||||
const isChatStreaming = computed(() => chatPanel.value?.isStreaming ?? false);
|
||||
const isChatLoadingHistory = computed(() => chatPanel.value?.isLoadingHistory ?? false);
|
||||
|
||||
interface SentResourceEntry {
|
||||
content: string;
|
||||
attachments: InstanceAiResourceAttachment[];
|
||||
}
|
||||
|
||||
function sentResourceStorageKey(): string {
|
||||
return `n8n-instance-ai-message-resources:${thread.id}`;
|
||||
}
|
||||
|
||||
function readSentResources(): SentResourceEntry[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(sentResourceStorageKey());
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as SentResourceEntry[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Mention chips ride the user bubble. Session storage keeps them across a reload. */
|
||||
const sentResourceLog = ref<SentResourceEntry[]>(readSentResources());
|
||||
|
||||
function rememberSentResources(content: string, attachments: InstanceAiResourceAttachment[]): void {
|
||||
if (attachments.length === 0) return;
|
||||
sentResourceLog.value = [...sentResourceLog.value, { content, attachments }];
|
||||
sessionStorage.setItem(sentResourceStorageKey(), JSON.stringify(sentResourceLog.value));
|
||||
}
|
||||
|
||||
const resourcesByMessageId = computed(() => {
|
||||
const pending = [...sentResourceLog.value];
|
||||
const byId = new Map<string, InstanceAiResourceAttachment[]>();
|
||||
for (const message of chatMessages.value) {
|
||||
if (message.role !== 'user') continue;
|
||||
const index = pending.findIndex((entry) => entry.content === message.content.trim());
|
||||
if (index < 0) continue;
|
||||
const [entry] = pending.splice(index, 1);
|
||||
if (!entry) continue;
|
||||
byId.set(message.id, entry.attachments);
|
||||
}
|
||||
return byId;
|
||||
});
|
||||
|
||||
function resourcesForChatMessage(message: ChatMessage): InstanceAiResourceAttachment[] {
|
||||
return resourcesByMessageId.value.get(message.id) ?? [];
|
||||
}
|
||||
|
||||
const mirroredMessages = computed(() =>
|
||||
agentsChatToThreadMessages(chatMessages.value, isChatStreaming.value),
|
||||
agentsChatToThreadMessages(chatMessages.value, isChatStreaming.value).map((message) => {
|
||||
if (message.role !== 'user') return message;
|
||||
const attachments = resourcesByMessageId.value.get(message.id);
|
||||
return attachments?.length ? { ...message, attachments } : message;
|
||||
}),
|
||||
);
|
||||
const isAwaitingInput = computed(() => findTailOpenInteractive(chatMessages.value) !== undefined);
|
||||
|
||||
|
|
@ -246,6 +318,111 @@ const pendingAgentAttachment = ref<InstanceAiAgentAttachment | null>(
|
|||
);
|
||||
/** Resource references staged in the composer (canvas node selections). */
|
||||
const composerResources = ref<InstanceAiResourceAttachment[]>([]);
|
||||
|
||||
const mentionsFlag = useIsAssistantAtMentionsEnabled();
|
||||
const draftText = ref('');
|
||||
const mentionFiles = ref<File[]>([]);
|
||||
const openWorkflowPreview = inject<((workflowId: string) => boolean) | undefined>(
|
||||
'openWorkflowPreview',
|
||||
undefined,
|
||||
);
|
||||
const shouldShowMentions = computed(
|
||||
() => mentionsFlag.value && Boolean(projectId.value) && !isTeammate.value,
|
||||
);
|
||||
const mentionArtifacts = computed((): WorkflowArtifactReference[] =>
|
||||
[...thread.producedArtifacts.values()]
|
||||
.filter((entry) => entry.type === 'workflow')
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
origin: thread.producedArtifactOrigins.get(entry.id),
|
||||
})),
|
||||
);
|
||||
const mentionAvailability = useAssistantMentionAvailability({
|
||||
enabled: shouldShowMentions,
|
||||
projectId: () => projectId.value,
|
||||
artifacts: () => mentionArtifacts.value,
|
||||
});
|
||||
const canUseMentions = computed(
|
||||
() => shouldShowMentions.value && mentionAvailability.isAvailable.value,
|
||||
);
|
||||
const mentions = useAssistantAtMentions({
|
||||
text: draftText,
|
||||
enabled: canUseMentions,
|
||||
getInputElement: () => chatPanel.value?.getInputElement() ?? undefined,
|
||||
});
|
||||
const mentionMenuOpen = mentions.menuOpen;
|
||||
const mentionQuery = mentions.query;
|
||||
const excludedMentionContext = computed(() => {
|
||||
const keys = new Set<string>();
|
||||
const workflowIds = new Set<string>();
|
||||
for (const attachment of composerResources.value) {
|
||||
if (attachment.type === 'workflow') {
|
||||
keys.add(buildMentionKey('workflow', attachment.id, attachment.id));
|
||||
workflowIds.add(attachment.id);
|
||||
continue;
|
||||
}
|
||||
if (attachment.type !== 'nodes') continue;
|
||||
for (const set of attachment.sets) {
|
||||
if (set.canvasGroupId) {
|
||||
keys.add(buildMentionKey('group', attachment.workflowId, set.canvasGroupId));
|
||||
continue;
|
||||
}
|
||||
for (const node of set.nodes) {
|
||||
keys.add(buildMentionKey('node', attachment.workflowId, node.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { keys: [...keys], workflowIds: [...workflowIds] };
|
||||
});
|
||||
const mentionAttachments = useAssistantMentionAttachments({
|
||||
files: mentionFiles,
|
||||
resources: composerResources,
|
||||
projectId: () => projectId.value,
|
||||
reservedAttachmentCount: () => 0,
|
||||
onReferenceAdded: (reference: AssistantMentionArtifactReference) => {
|
||||
thread.upsertTransientWorkflowReference({
|
||||
referenceId: reference.referenceId,
|
||||
workflowId: reference.workflowId,
|
||||
workflowName: reference.workflowName,
|
||||
...(projectId.value ? { projectId: projectId.value } : {}),
|
||||
});
|
||||
void nextTick(() => {
|
||||
openWorkflowPreview?.(reference.workflowId);
|
||||
});
|
||||
},
|
||||
onReferenceRemoved: (referenceId) => {
|
||||
thread.removeTransientWorkflowReference(referenceId);
|
||||
},
|
||||
onCleared: () => mentions.close(false, 'unavailable'),
|
||||
});
|
||||
// Availability can resolve after the first `@`. Re-read the draft so the menu still opens.
|
||||
watch(canUseMentions, (enabled, wasEnabled) => {
|
||||
if (enabled && !wasEnabled) void mentions.handleTextChange(draftText.value);
|
||||
});
|
||||
|
||||
function onDraftUpdate(value: string): void {
|
||||
if (!shouldShowMentions.value) return;
|
||||
void mentions.handleTextChange(value);
|
||||
}
|
||||
|
||||
async function handleMentionSelection(selection: AssistantMentionSelection): Promise<void> {
|
||||
const result = mentionAttachments.select(selection);
|
||||
if (result.status === 'limit') {
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('instanceAi.mentions.attachmentLimitMessage')),
|
||||
i18n.baseText('instanceAi.mentions.attachmentLimitTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.truncated) {
|
||||
toast.showError(
|
||||
new Error(i18n.baseText('instanceAi.nodeContext.truncated.message')),
|
||||
i18n.baseText('instanceAi.nodeContext.truncated.title'),
|
||||
);
|
||||
}
|
||||
await mentions.replaceActiveRange(selection.item.label);
|
||||
}
|
||||
/** Text n8n wrote into the composer, to tell a pre-fill from a typed message. */
|
||||
const activePrefill = ref<InstanceAiPrefillPayload | null>(null);
|
||||
/** A programmatic send: its context replaces the composer state for one message. */
|
||||
|
|
@ -436,6 +613,14 @@ function readHostAttachments(hostContext: Record<string, unknown>): InstanceAiAt
|
|||
return Array.isArray(attachments) ? (attachments as InstanceAiAttachment[]) : [];
|
||||
}
|
||||
|
||||
function resourceAttachmentsOf(
|
||||
attachments: InstanceAiAttachment[],
|
||||
): InstanceAiResourceAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is InstanceAiResourceAttachment => attachment.type !== 'file',
|
||||
);
|
||||
}
|
||||
|
||||
/** A message the user typed was accepted: settle the hand-off state it carried. */
|
||||
function onMessageAccepted(payload: {
|
||||
text: string;
|
||||
|
|
@ -446,7 +631,11 @@ function onMessageAccepted(payload: {
|
|||
messageRunTarget.accepted(hostContext);
|
||||
const attachments = readHostAttachments(hostContext);
|
||||
thread.recordSentAttachments(attachments);
|
||||
if (programmaticHostContexts.has(hostContext)) return;
|
||||
// The empty view sends the opener. Its chips are on that payload, not in this composer.
|
||||
if (programmaticHostContexts.has(hostContext)) {
|
||||
rememberSentResources(payload.text.trim(), resourceAttachmentsOf(attachments));
|
||||
return;
|
||||
}
|
||||
|
||||
const prefill = activePrefill.value;
|
||||
const authorship: InstanceAiMessageAuthorship = prefill
|
||||
|
|
@ -463,6 +652,7 @@ function onMessageAccepted(payload: {
|
|||
attachmentCount: attachments.length + payload.files.length,
|
||||
});
|
||||
|
||||
rememberSentResources(payload.text.trim(), [...composerResources.value]);
|
||||
const nodeCount = countAttachedNodes(attachments);
|
||||
if (nodeCount > 0) {
|
||||
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_CHAT_MESSAGE_WITH_NODES, {
|
||||
|
|
@ -616,7 +806,13 @@ onBeforeUnmount(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.conversation" data-test-id="instance-ai-agents-conversation">
|
||||
<div
|
||||
:class="$style.conversation"
|
||||
data-test-id="instance-ai-agents-conversation"
|
||||
@pointerdown.capture="shouldShowMentions && mentions.saveSelection()"
|
||||
@click.capture="shouldShowMentions && mentions.handleCaretMove()"
|
||||
@keyup.capture="shouldShowMentions && mentions.handleCaretMove()"
|
||||
>
|
||||
<AgentChatPanel
|
||||
v-if="projectId"
|
||||
ref="chatPanel"
|
||||
|
|
@ -632,7 +828,10 @@ onBeforeUnmount(() => {
|
|||
attachment-accept=""
|
||||
:show-attach-button="false"
|
||||
:placeholder="composerPlaceholder"
|
||||
:input-draft="shouldShowMentions ? draftText : undefined"
|
||||
tag-instance-messages
|
||||
mode="inline"
|
||||
@update:input-draft="onDraftUpdate"
|
||||
@message-accepted="onMessageAccepted"
|
||||
@resume-failed="sharing.onResumeFailed"
|
||||
>
|
||||
|
|
@ -664,6 +863,27 @@ onBeforeUnmount(() => {
|
|||
<template #footer-start>
|
||||
<InstanceAiInputMenu :thread-id="thread.id" @attach-files="chatPanel?.openFilePicker()" />
|
||||
</template>
|
||||
<template v-if="shouldShowMentions" #right-actions>
|
||||
<AssistantAtMentionPicker
|
||||
v-model="mentionMenuOpen"
|
||||
:query="mentionQuery"
|
||||
:project-id="projectId"
|
||||
:artifacts="mentionArtifacts"
|
||||
:excluded-keys="excludedMentionContext.keys"
|
||||
:excluded-workflow-ids="excludedMentionContext.workflowIds"
|
||||
:input-element="chatPanel?.getInputElement() ?? null"
|
||||
:disabled="!canUseMentions"
|
||||
@update:model-value="mentions.handleMenuOpenChange"
|
||||
@select="handleMentionSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #user-message-extra="{ message }">
|
||||
<AttachmentPreview
|
||||
v-for="(attachment, index) in resourcesForChatMessage(message)"
|
||||
:key="`sent-${message.id}-${index}`"
|
||||
:attachment="attachment"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="composerContextChip || composerResources.length" #composer-attachments>
|
||||
<InstanceAiResourceChip
|
||||
v-if="composerContextChip"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { CODE_MODE_CSS, CODE_MODE_HTML, CODE_MODE_PREVIEW_DOCUMENT } from '../codeMode/codeMode';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const view = ref<'preview' | 'code'>('preview');
|
||||
const activeFile = ref<'index.html' | 'styles.css'>('index.html');
|
||||
const promoteOpen = ref(false);
|
||||
const published = ref(false);
|
||||
|
||||
const fileSource = computed(() =>
|
||||
activeFile.value === 'index.html' ? CODE_MODE_HTML : CODE_MODE_CSS,
|
||||
);
|
||||
|
||||
const files = [
|
||||
{ id: 'index.html' as const, label: 'index.html' },
|
||||
{ id: 'styles.css' as const, label: 'styles.css' },
|
||||
];
|
||||
|
||||
function showFile(file: 'index.html' | 'styles.css') {
|
||||
activeFile.value = file;
|
||||
view.value = 'code';
|
||||
}
|
||||
|
||||
function keepInChat() {
|
||||
promoteOpen.value = false;
|
||||
published.value = false;
|
||||
}
|
||||
|
||||
function publishDraft() {
|
||||
published.value = true;
|
||||
promoteOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="$style.panel" data-test-id="instance-ai-code-mode">
|
||||
<header :class="$style.header">
|
||||
<div :class="$style.titleRow">
|
||||
<N8nText tag="h2" size="medium" bold>{{
|
||||
i18n.baseText('instanceAi.codeMode.title')
|
||||
}}</N8nText>
|
||||
<span :class="$style.chip">{{ i18n.baseText('instanceAi.codeMode.worktree') }}</span>
|
||||
<span :class="$style.chip">{{ i18n.baseText('instanceAi.codeMode.draft') }}</span>
|
||||
</div>
|
||||
<N8nIconButton
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:aria-label="i18n.baseText('instanceAi.codeMode.close')"
|
||||
data-test-id="instance-ai-code-mode-close"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div :class="$style.toolbar" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="view === 'preview'"
|
||||
:class="[$style.tab, view === 'preview' && $style.tabActive]"
|
||||
data-test-id="instance-ai-code-mode-preview"
|
||||
@click="view = 'preview'"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.codeMode.preview') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="view === 'code'"
|
||||
:class="[$style.tab, view === 'code' && $style.tabActive]"
|
||||
data-test-id="instance-ai-code-mode-code"
|
||||
@click="view = 'code'"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.codeMode.code') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="$style.body">
|
||||
<iframe
|
||||
v-if="view === 'preview'"
|
||||
:class="$style.preview"
|
||||
sandbox=""
|
||||
:title="i18n.baseText('instanceAi.codeMode.previewTitle')"
|
||||
:srcdoc="CODE_MODE_PREVIEW_DOCUMENT"
|
||||
data-test-id="instance-ai-code-mode-frame"
|
||||
/>
|
||||
<div v-else :class="$style.codeLayout">
|
||||
<div :class="$style.files" role="list">
|
||||
<button
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
type="button"
|
||||
role="listitem"
|
||||
:class="[$style.file, activeFile === file.id && $style.fileActive]"
|
||||
@click="showFile(file.id)"
|
||||
>
|
||||
{{ file.label }}
|
||||
</button>
|
||||
</div>
|
||||
<pre :class="$style.source" data-test-id="instance-ai-code-mode-source">{{
|
||||
fileSource
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer :class="$style.footer">
|
||||
<N8nText v-if="published" size="small">{{
|
||||
i18n.baseText('instanceAi.codeMode.published')
|
||||
}}</N8nText>
|
||||
<template v-else-if="promoteOpen">
|
||||
<N8nText size="small">{{ i18n.baseText('instanceAi.codeMode.promoteBody') }}</N8nText>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton size="small" variant="outline" @click="keepInChat">
|
||||
{{ i18n.baseText('instanceAi.codeMode.keep') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="small"
|
||||
data-test-id="instance-ai-code-mode-publish"
|
||||
@click="publishDraft"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.codeMode.publish') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :class="$style.actions">
|
||||
<N8nButton size="small" variant="outline" @click="keepInChat">
|
||||
{{ i18n.baseText('instanceAi.codeMode.keep') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
size="small"
|
||||
data-test-id="instance-ai-code-mode-promote"
|
||||
@click="promoteOpen = true"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.codeMode.promote') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
background: var(--color--background--light-2);
|
||||
border-left: var(--border);
|
||||
}
|
||||
|
||||
.header,
|
||||
.toolbar,
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
justify-content: space-between;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--5xs) var(--spacing--2xs);
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color--text--tint-1);
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius--sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: var(--color--foreground--tint-2);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.preview {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: var(--color--background--light-3);
|
||||
}
|
||||
|
||||
.codeLayout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
width: var(--spacing--4xl);
|
||||
padding: var(--spacing--xs);
|
||||
border-right: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius--sm);
|
||||
cursor: pointer;
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.fileActive {
|
||||
background: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.source {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: var(--spacing--sm);
|
||||
overflow: auto;
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: var(--line-height--sm);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: var(--border);
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
const emit = defineEmits<{ 'build-website': [] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
titleKey: 'instanceAi.projectHome.agents.title' as const,
|
||||
bodyKey: 'instanceAi.projectHome.agents.body' as const,
|
||||
},
|
||||
{
|
||||
titleKey: 'instanceAi.projectHome.approvals.title' as const,
|
||||
bodyKey: 'instanceAi.projectHome.approvals.body' as const,
|
||||
},
|
||||
{
|
||||
titleKey: 'instanceAi.projectHome.people.title' as const,
|
||||
bodyKey: 'instanceAi.projectHome.people.body' as const,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="$style.home" data-test-id="instance-ai-project-home">
|
||||
<div :class="$style.heading">
|
||||
<div :class="$style.titles">
|
||||
<N8nText tag="h2" size="large" bold>{{
|
||||
i18n.baseText('instanceAi.projectHome.title')
|
||||
}}</N8nText>
|
||||
<N8nText size="small" color="text-light">{{
|
||||
i18n.baseText('instanceAi.projectHome.lead')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<N8nButton
|
||||
size="small"
|
||||
icon="code"
|
||||
data-test-id="instance-ai-build-website"
|
||||
@click="emit('build-website')"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.projectHome.buildWebsite') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<N8nCallout theme="info" slim>
|
||||
{{ i18n.baseText('instanceAi.projectHome.mcp') }}
|
||||
</N8nCallout>
|
||||
<div :class="$style.cards">
|
||||
<article v-for="card in cards" :key="card.titleKey" :class="$style.card">
|
||||
<N8nText tag="h3" size="small" bold>{{ i18n.baseText(card.titleKey) }}</N8nText>
|
||||
<N8nText size="small" color="text-light">{{ i18n.baseText(card.bodyKey) }}</N8nText>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.home {
|
||||
width: 100%;
|
||||
/* Matches the composer width. No spacing token is 680px. */
|
||||
max-width: 680px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--sm);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
background: var(--color--background--light-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -109,7 +109,6 @@ const pendingWorkflowAttachmentKey = (threadId: string) =>
|
|||
const pendingRedirectLandingKey = (threadId: string) =>
|
||||
`n8n-instance-ai-redirect-landing:${threadId}`;
|
||||
|
||||
|
||||
export function buildInstanceAiCredentialHandoffContext(
|
||||
credential: InstanceAiCredentialContext,
|
||||
): InstanceAiHandoffContext {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { readHomeMode, writeHomeMode } from './homeMode';
|
||||
|
||||
describe('assistant home mode', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('starts in the streamlined user mode', () => {
|
||||
expect(readHomeMode()).toBe('user');
|
||||
});
|
||||
|
||||
it('remembers power mode', () => {
|
||||
writeHomeMode('power');
|
||||
expect(readHomeMode()).toBe('power');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
export type AssistantHomeMode = 'user' | 'power';
|
||||
|
||||
const STORAGE_KEY = 'n8n-instance-ai-home-mode';
|
||||
|
||||
/** User mode is the default. Power is the only stored choice. */
|
||||
export function readHomeMode(): AssistantHomeMode {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'power' ? 'power' : 'user';
|
||||
} catch {
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHomeMode(mode: AssistantHomeMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Private browsing can reject storage. The choice still applies in this view.
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,8 @@ export interface AgentsChatMessage {
|
|||
interactives?: InteractivePayload[];
|
||||
interactive?: InteractivePayload;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
/** Set when the turn failed for a known reason, such as an exhausted credit pool. */
|
||||
errorCode?: string;
|
||||
/** Persisted agent execution id for this turn (history parse or live SSE `done`). */
|
||||
executionId?: string;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"test:evals:smoke": "playwright test --project=eval:smoke",
|
||||
"test:unit": "vitest run",
|
||||
"test:harness": "vitest run --config vitest.harness.config.ts",
|
||||
"distribution:count": "pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... 1>&2 && node scripts/count-test-distribution.mjs",
|
||||
"distribution:count": "pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types... 1>&2 && node scripts/count-test-distribution.mjs",
|
||||
"coverage:shard": "node scripts/run-coverage-shard.mjs",
|
||||
"coverage:emit-shard": "tsx scripts/emit-shard-coverage.ts",
|
||||
"coverage:emit-spec-lcovs": "tsx scripts/emit-spec-lcovs.ts",
|
||||
|
|
|
|||
|
|
@ -94,6 +94,66 @@ export class ExperienceModes {
|
|||
return this.page.getByTestId('automation-proposal-resolved-status');
|
||||
}
|
||||
|
||||
/** The line that names where the automation runs. */
|
||||
getProposalPlace(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-place');
|
||||
}
|
||||
|
||||
/** The note that a local automation runs only while this computer is on. */
|
||||
getProposalCaveat(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-caveat');
|
||||
}
|
||||
|
||||
/** "Change" on the place line. Power mode shows it when the card offers more than one place. */
|
||||
getProposalChangeTarget(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-change-target');
|
||||
}
|
||||
|
||||
/** One place in the open "Where should it run?" menu, by the start of its name. */
|
||||
getProposalTargetOption(name: RegExp): Locator {
|
||||
return this.page.getByRole('menuitemcheckbox', { name });
|
||||
}
|
||||
|
||||
/** What the linked instance needs before the workflow goes there. */
|
||||
getProposalPreflight(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-preflight');
|
||||
}
|
||||
|
||||
/** The line that names credentials the linked instance does not have. */
|
||||
getProposalPreflightSetUp(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-preflight-set-up');
|
||||
}
|
||||
|
||||
/** "Set up in {place}" on the preflight notice. It opens the credentials of that instance. */
|
||||
getProposalPreflightSetUpLink(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-preflight-set-up-link');
|
||||
}
|
||||
|
||||
/** The line that says the workflow cannot go to the linked instance. */
|
||||
getProposalPreflightBlocked(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-preflight-blocked');
|
||||
}
|
||||
|
||||
/** "Keep it on this computer" on the blocked notice. */
|
||||
getProposalKeepHere(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-preflight-keep-here');
|
||||
}
|
||||
|
||||
/** "Save, but leave it off" on the proposal card. */
|
||||
getProposalSaveButton(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-save');
|
||||
}
|
||||
|
||||
/** "Not now" on the proposal card. */
|
||||
getProposalNotNowButton(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-not-now');
|
||||
}
|
||||
|
||||
/** The link that opens the copy in the linked instance. */
|
||||
getProposalOpenRemote(): Locator {
|
||||
return this.page.getByTestId('automation-proposal-open-remote');
|
||||
}
|
||||
|
||||
/** One group of the chat list in Power mode. */
|
||||
getChatGroup(group: 'needs-you' | 'working' | 'ready' | 'done'): Locator {
|
||||
return this.page.getByTestId(`assistant-chat-group-${group}`);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
* node distribute-tests.mjs <shards> <index> # Specs for a single shard
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execFileSync, spawnSync } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -194,23 +194,31 @@ function generateDistributionGroups(project, grepInvert) {
|
|||
const temp = mkdtempSync(path.join(tmpdir(), 'distribution-groups-'));
|
||||
const reportPath = path.join(temp, 'playwright-profiles.json');
|
||||
const groupsPath = path.join(temp, 'groups.json');
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
PLAYWRIGHT_CLI,
|
||||
'test',
|
||||
'--list',
|
||||
`--project=${project}`,
|
||||
'--workers=1',
|
||||
`--reporter=${DISTRIBUTION_REPORTER}`,
|
||||
...(grepInvert ? [`--grep-invert=${grepInvert}`] : []),
|
||||
],
|
||||
{
|
||||
cwd: PLAYWRIGHT_DIR,
|
||||
env: { ...process.env, DISTRIBUTION_COUNTER_OUTPUT: reportPath },
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
},
|
||||
);
|
||||
const args = [
|
||||
PLAYWRIGHT_CLI,
|
||||
'test',
|
||||
'--list',
|
||||
`--project=${project}`,
|
||||
'--workers=1',
|
||||
`--reporter=${DISTRIBUTION_REPORTER}`,
|
||||
...(grepInvert ? [`--grep-invert=${grepInvert}`] : []),
|
||||
];
|
||||
// The custom reporter replaces the list reporter, so a load error stays on
|
||||
// the child stderr. Print that stderr. Otherwise the parent only sees exit 1.
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: PLAYWRIGHT_DIR,
|
||||
env: { ...process.env, DISTRIBUTION_COUNTER_OUTPUT: reportPath },
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
if (result.stdout) process.stderr.write(result.stdout);
|
||||
const signal = result.signal ? ` signal ${result.signal}` : '';
|
||||
throw new Error(`Command failed (${result.status ?? 'none'}${signal}): ${args.join(' ')}`);
|
||||
}
|
||||
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||||
const groups = buildDistributionGroups(report);
|
||||
writeFileSync(groupsPath, JSON.stringify(groups));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
/* eslint-disable import-x/no-default-export -- Playwright loads a default reporter export. */
|
||||
import type { FullConfig, Reporter, Suite, TestCase } from '@playwright/test/reporter';
|
||||
import type { FullConfig, Reporter, Suite, TestCase, TestError } from '@playwright/test/reporter';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
function listedTestLabel(test: TestCase): string {
|
||||
const title = test.titlePath().join(' › ');
|
||||
return `${test.location.file}:${test.location.line} ${title}`;
|
||||
}
|
||||
|
||||
function workerHash(test: TestCase): string {
|
||||
const value: unknown = Reflect.get(test, '_workerHash');
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
// The runner hides a reporter exception. Print the test before the throw.
|
||||
console.error(`Playwright did not expose a worker hash for ${listedTestLabel(test)}`);
|
||||
throw new Error('Playwright did not expose a worker hash for a listed test');
|
||||
}
|
||||
return value;
|
||||
|
|
@ -14,6 +21,7 @@ function workerHash(test: TestCase): string {
|
|||
function poolDigest(test: TestCase): string {
|
||||
const value: unknown = Reflect.get(test, '_poolDigest');
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
console.error(`Playwright did not expose a fixture-pool digest for ${listedTestLabel(test)}`);
|
||||
throw new Error('Playwright did not expose a fixture-pool digest for a listed test');
|
||||
}
|
||||
return value;
|
||||
|
|
@ -24,6 +32,13 @@ function relativeSpec(file: string): string {
|
|||
}
|
||||
|
||||
export default class DistributionCounterReporter implements Reporter {
|
||||
// Playwright hides a reporter exception and does not print a load error
|
||||
// when this reporter replaces the list reporter. Write the error here.
|
||||
onError(error: TestError): void {
|
||||
const text = error.stack ?? error.message;
|
||||
if (text) console.error(text);
|
||||
}
|
||||
|
||||
onBegin(_config: FullConfig, suite: Suite): void {
|
||||
const output = process.env.DISTRIBUTION_COUNTER_OUTPUT;
|
||||
if (!output) throw new Error('DISTRIBUTION_COUNTER_OUTPUT is required');
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ export function baseInstanceEnv(instance, userFolder) {
|
|||
/** Assistant variables for "This computer". The model is the scripted LLM. */
|
||||
export function localAssistantEnv({ llmPort, sandboxPort }) {
|
||||
return {
|
||||
N8N_ENABLED_MODULES: 'instance-ai',
|
||||
// instance-ai is a default module. linked-instances is off until it is named here,
|
||||
// and the automation card offers the cloud only while it is on.
|
||||
N8N_ENABLED_MODULES: 'instance-ai,linked-instances',
|
||||
N8N_INSTANCE_AI_MODEL: 'anthropic/claude-scripted',
|
||||
// Keep `/v1`: the proxy code path does not add it.
|
||||
N8N_INSTANCE_AI_MODEL_URL: `http://127.0.0.1:${llmPort}/v1`,
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ describe('localAssistantEnv', () => {
|
|||
const env = localAssistantEnv({ llmPort: 4010, sandboxPort: 4011 });
|
||||
|
||||
expect(env).toMatchObject({
|
||||
N8N_ENABLED_MODULES: 'instance-ai',
|
||||
N8N_ENABLED_MODULES: 'instance-ai,linked-instances',
|
||||
N8N_INSTANCE_AI_MODEL: 'anthropic/claude-scripted',
|
||||
N8N_INSTANCE_AI_MODEL_URL: 'http://127.0.0.1:4010/v1',
|
||||
N8N_INSTANCE_AI_MODEL_API_KEY: 'scripted',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
InstanceAiPermissions,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiThreadInfo,
|
||||
LinkedInstanceSummary,
|
||||
} from '@n8n/api-types';
|
||||
import { request, type APIRequestContext } from '@playwright/test';
|
||||
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||
|
|
@ -806,6 +807,25 @@ export class ApiHelpers {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link another n8n instance for the signed-in user. The server checks the
|
||||
* address with the token before it stores the link. The response has no token.
|
||||
*/
|
||||
async linkInstance(input: {
|
||||
name: string;
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<LinkedInstanceSummary> {
|
||||
const response = await this.request.post('/rest/linked-instances', { data: input });
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(`Failed to link instance: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data ?? result;
|
||||
}
|
||||
|
||||
// ===== PRIVATE METHODS =====
|
||||
|
||||
private async loginAndSetCookies(
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ import type { APIResponse } from '@playwright/test';
|
|||
|
||||
import type { ApiHelpers } from './api-helper';
|
||||
|
||||
/** A binary file attached to a chat message, as the chat endpoint expects it. */
|
||||
/** A binary file attached to an Assistant chat message, as the agents chat endpoint expects it. */
|
||||
export interface InstanceAiFileAttachmentPayload {
|
||||
type: 'file';
|
||||
/** Base64-encoded contents. */
|
||||
data: string;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/** The code-defined n8n Assistant agent. */
|
||||
const ASSISTANT_AGENT_ID = 'n8n-assistant';
|
||||
|
||||
/**
|
||||
* Helper for driving Instance AI endpoints directly, bypassing the editor UI.
|
||||
*/
|
||||
|
|
@ -25,10 +27,13 @@ export class InstanceAiApiHelper {
|
|||
* non-2xx status is the expected outcome rather than an error.
|
||||
*/
|
||||
async sendMessageResponse(
|
||||
threadId: string,
|
||||
projectId: string,
|
||||
payload: { message?: string; attachments?: InstanceAiFileAttachmentPayload[] },
|
||||
): Promise<APIResponse> {
|
||||
return await this.api.request.post(`/rest/instance-ai/chat/${threadId}`, { data: payload });
|
||||
return await this.api.request.post(
|
||||
`/rest/projects/${projectId}/agents/v2/${ASSISTANT_AGENT_ID}/chat`,
|
||||
{ data: payload },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ Both instances: `E2E_TESTS=true`, `N8N_LISTEN_ADDRESS=127.0.0.1`,
|
|||
|
||||
"This computer" also gets:
|
||||
|
||||
- `N8N_ENABLED_MODULES=instance-ai`.
|
||||
- `N8N_ENABLED_MODULES=instance-ai,linked-instances`. The automation card
|
||||
offers the cloud only while the linked-instances module is on.
|
||||
- `N8N_INSTANCE_AI_MODEL=anthropic/claude-scripted`,
|
||||
`N8N_INSTANCE_AI_MODEL_URL` (the scripted LLM, with `/v1`) and
|
||||
`N8N_INSTANCE_AI_MODEL_API_KEY=scripted`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,572 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import type { n8nPage } from '../../../pages/n8nPage';
|
||||
import type { ApiHelpers } from '../../../services/api-helper';
|
||||
import type { ScriptInput } from '../../../services/scripted-llm/scripted-llm.types';
|
||||
import { TestError } from '../../../Types';
|
||||
import { expect, requireLinkedInstances, test, type AssistantRun } from './fixtures';
|
||||
|
||||
type Mode = 'simple' | 'power';
|
||||
|
||||
// The system prompt of the Assistant agent starts with this text. The scripted rules use it
|
||||
// to skip title and memory calls.
|
||||
const AGENT_PROMPT = 'n8n Instance Agent';
|
||||
const CLOUD_NAME = 'Cloud';
|
||||
const PROPOSAL_REPLY = 'I can keep this and turn it on.';
|
||||
const PROPOSAL_TITLE = 'Morning digest';
|
||||
const PROPOSAL_ASK = 'Turn it into an automation';
|
||||
const CREDENTIAL_NAME = 'Stripe key';
|
||||
// Chats wait for the scripted model, the card and the copy on the other instance.
|
||||
const STEP_TIMEOUT_MS = 60_000;
|
||||
|
||||
type NamedWorkflow = { id: string; name: string; active: boolean };
|
||||
|
||||
requireLinkedInstances();
|
||||
|
||||
test.describe(
|
||||
'Automation proposals on a linked instance',
|
||||
{ annotation: [{ type: 'owner', description: 'instanceAI' }] },
|
||||
() => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('Power mode recommends the cloud, and Turn it on copies the workflow there and turns it on', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Cloud digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(CLOUD_NAME);
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
|
||||
'it keeps going when this computer is off',
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeVisible();
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await n8n.experienceModes.getProposalTurnOnButton().click();
|
||||
await expectResolvedIn(n8n, 'on', CLOUD_NAME);
|
||||
const remote = n8n.experienceModes.getProposalOpenRemote();
|
||||
await expect(remote).toBeVisible();
|
||||
await expect(remote).toHaveAttribute(
|
||||
'href',
|
||||
new RegExp(`^${escapeRegExp(cloudUrl)}/workflow/`),
|
||||
);
|
||||
await expect(remote).toHaveAttribute('target', '_blank');
|
||||
|
||||
const copy = await waitForWorkflow(cloudApi, workflowName);
|
||||
expect(copy.active).toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('Power mode can keep the automation on this computer instead', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Local digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await n8n.experienceModes.getProposalChangeTarget().click();
|
||||
await n8n.experienceModes.getProposalTargetOption(/^This computer/).click();
|
||||
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
|
||||
await expect(n8n.experienceModes.getProposalCaveat()).toContainText(
|
||||
'Only runs while this computer is on.',
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
|
||||
|
||||
await n8n.experienceModes.getProposalTurnOnButton().click();
|
||||
await expectResolvedIn(n8n, 'on', 'This computer');
|
||||
await expect
|
||||
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active, {
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
})
|
||||
.toBe(true);
|
||||
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a missing credential holds Turn it on, and Save copies the workflow switched off', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Credential digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const credential = await n8n.api.credentials.createCredential({
|
||||
name: CREDENTIAL_NAME,
|
||||
type: 'httpHeaderAuth',
|
||||
data: { name: 'Authorization', value: 'secret' },
|
||||
});
|
||||
const workflow = await n8n.api.workflows.createWorkflow(
|
||||
credentialWorkflow(workflowName, credential.id),
|
||||
);
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
|
||||
await expect(n8n.experienceModes.getProposalPreflightSetUp()).toContainText(CREDENTIAL_NAME, {
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
await expect(n8n.experienceModes.getProposalPreflightSetUp()).toContainText(CLOUD_NAME);
|
||||
const setUp = n8n.experienceModes.getProposalPreflightSetUpLink();
|
||||
await expect(setUp).toBeVisible();
|
||||
await expect(setUp).toHaveAttribute(
|
||||
'href',
|
||||
`${cloudUrl.replace(/\/+$/, '')}/home/credentials`,
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeDisabled();
|
||||
await expect(n8n.experienceModes.getProposalSaveButton()).toBeEnabled();
|
||||
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled();
|
||||
|
||||
await n8n.experienceModes.getProposalSaveButton().click();
|
||||
await expectResolvedIn(n8n, 'saved', CLOUD_NAME);
|
||||
|
||||
const copy = await waitForWorkflow(cloudApi, workflowName);
|
||||
expect(copy.active).toBe(false);
|
||||
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
|
||||
});
|
||||
|
||||
test('Not now leaves the workflow off on both instances', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Declined digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await n8n.experienceModes.getProposalNotNowButton().click();
|
||||
await expectResolved(n8n, 'declined');
|
||||
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
|
||||
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Simple mode shows only the cloud recommendation and still turns the copy on there', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Simple digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'simple');
|
||||
|
||||
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeHidden();
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(CLOUD_NAME);
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
|
||||
'it keeps going when this computer is off',
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await n8n.experienceModes.getProposalTurnOnButton().click();
|
||||
await expectResolvedIn(n8n, 'on', CLOUD_NAME);
|
||||
const copy = await waitForWorkflow(cloudApi, workflowName);
|
||||
expect(copy.active).toBe(true);
|
||||
});
|
||||
|
||||
test('a command on this computer is recommended here, and Power mode can still choose the cloud', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Local command ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(commandWorkflow(workflowName));
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
|
||||
'it runs commands on this computer',
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalCaveat()).toBeVisible();
|
||||
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
|
||||
|
||||
await n8n.experienceModes.getProposalChangeTarget().click();
|
||||
await n8n.experienceModes.getProposalTargetOption(/^Cloud/).click();
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText(`Runs on ${CLOUD_NAME}`);
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await n8n.experienceModes.getProposalChangeTarget().click();
|
||||
await n8n.experienceModes.getProposalTargetOption(/^This computer/).click();
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
|
||||
await expect(n8n.experienceModes.getProposalCaveat()).toBeVisible();
|
||||
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled();
|
||||
// Choosing a place does not copy the workflow. The card is still open.
|
||||
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
|
||||
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
|
||||
});
|
||||
|
||||
test('a workflow that calls another workflow by ID offers only this computer', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Parent digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
// A parent turns on only when the workflow it calls is already on.
|
||||
const child = await n8n.api.workflows.createWorkflow(scheduleWorkflow(`Child ${nanoid(8)}`));
|
||||
await publishWorkflow(n8n.api, child.id);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(
|
||||
callingWorkflow(workflowName, child.id),
|
||||
);
|
||||
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
|
||||
|
||||
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeHidden();
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
|
||||
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
|
||||
|
||||
await n8n.experienceModes.getProposalTurnOnButton().click();
|
||||
await expectResolvedIn(n8n, 'on', 'This computer');
|
||||
await expect
|
||||
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active, {
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
})
|
||||
.toBe(true);
|
||||
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a call added after the card keeps the workflow here until the user chooses this computer', async ({
|
||||
n8n,
|
||||
cloudApi,
|
||||
cloudUrl,
|
||||
startLlm,
|
||||
startAssistantRun,
|
||||
}) => {
|
||||
const workflowName = `Changed digest ${nanoid(8)}`;
|
||||
await linkCloud(n8n.api, cloudApi, cloudUrl);
|
||||
const child = await n8n.api.workflows.createWorkflow(
|
||||
manualWorkflow(`Later child ${nanoid(8)}`),
|
||||
);
|
||||
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
|
||||
const chatId = await propose(n8n, startLlm, startAssistantRun, workflow.id);
|
||||
await replaceWorkflow(n8n.api, workflow.id, callingWorkflow(workflowName, child.id));
|
||||
|
||||
await n8n.api.users.setExperienceMode('power');
|
||||
await n8n.start.fromInstanceAiThread(chatId);
|
||||
await expect(n8n.experienceModes.getProposalCard()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
|
||||
await expect(n8n.experienceModes.getProposalPreflightBlocked()).toContainText(
|
||||
"It can't go to Cloud, because it calls other workflows by ID.",
|
||||
{ timeout: STEP_TIMEOUT_MS },
|
||||
);
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeDisabled();
|
||||
await expect(n8n.experienceModes.getProposalSaveButton()).toBeDisabled();
|
||||
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled();
|
||||
|
||||
await n8n.experienceModes.getProposalKeepHere().click();
|
||||
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
|
||||
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
|
||||
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** Turns the workflow on and waits until it is active. */
|
||||
async function publishWorkflow(api: ApiHelpers, workflowId: string): Promise<void> {
|
||||
const current = await api.workflows.getWorkflow(workflowId);
|
||||
const versionId = current.versionId ?? '';
|
||||
expect(versionId).not.toEqual('');
|
||||
await api.workflows.activate(workflowId, versionId);
|
||||
await expect
|
||||
.poll(async () => (await api.workflows.getWorkflow(workflowId)).active, {
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/** Saves a new version of the workflow. The card still shows the version it proposed. */
|
||||
async function replaceWorkflow(
|
||||
api: ApiHelpers,
|
||||
workflowId: string,
|
||||
workflow: Partial<IWorkflowBase>,
|
||||
): Promise<void> {
|
||||
const current = await api.workflows.getWorkflow(workflowId);
|
||||
const versionId = current.versionId ?? '';
|
||||
expect(versionId).not.toEqual('');
|
||||
await api.workflows.update(workflowId, versionId, workflow);
|
||||
}
|
||||
|
||||
/** Turns on MCP on Cloud and stores the link on this computer. */
|
||||
async function linkCloud(api: ApiHelpers, cloudApi: ApiHelpers, cloudUrl: string): Promise<void> {
|
||||
await cloudApi.setMcpAccess(true);
|
||||
const { apiKey } = await cloudApi.rotateMcpApiKey();
|
||||
const link = await api.linkInstance({ name: CLOUD_NAME, url: cloudUrl, token: apiKey });
|
||||
expect(link.status).toBe('online');
|
||||
}
|
||||
|
||||
/** Asks for the proposal and returns the chat id. The page is not open yet. */
|
||||
async function propose(
|
||||
n8n: n8nPage,
|
||||
startLlm: (script: ScriptInput) => Promise<unknown>,
|
||||
startAssistantRun: (threadId: string, message: string) => Promise<AssistantRun>,
|
||||
workflowId: string,
|
||||
): Promise<string> {
|
||||
const chat = await n8n.api.createInstanceAiThread();
|
||||
await n8n.api.renameInstanceAiThread(chat.id, `Automation ${nanoid(6)}`);
|
||||
await startLlm(proposeScript(workflowId));
|
||||
const run = await startAssistantRun(chat.id, PROPOSAL_ASK);
|
||||
await expect
|
||||
.poll(() => suspensionsOf(run).length, { timeout: STEP_TIMEOUT_MS })
|
||||
.toBeGreaterThan(0);
|
||||
run.disconnect();
|
||||
return chat.id;
|
||||
}
|
||||
|
||||
/** Proposes the workflow, then opens the chat in the given mode so the card is on screen. */
|
||||
async function openProposal(
|
||||
n8n: n8nPage,
|
||||
startLlm: (script: ScriptInput) => Promise<unknown>,
|
||||
startAssistantRun: (threadId: string, message: string) => Promise<AssistantRun>,
|
||||
workflowId: string,
|
||||
mode: Mode,
|
||||
): Promise<void> {
|
||||
const chatId = await propose(n8n, startLlm, startAssistantRun, workflowId);
|
||||
await n8n.api.users.setExperienceMode(mode);
|
||||
await n8n.start.fromInstanceAiThread(chatId);
|
||||
await expect(n8n.instanceAi.getPanelText(PROPOSAL_REPLY)).toBeVisible({
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
});
|
||||
await expect(n8n.experienceModes.getProposalCard()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
/** The answered card shows this outcome. */
|
||||
async function expectResolved(n8n: n8nPage, status: string): Promise<void> {
|
||||
await expect(n8n.experienceModes.getProposalResolved()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
|
||||
await expect(n8n.experienceModes.getProposalCard()).toBeHidden();
|
||||
await expect(n8n.experienceModes.getProposalResolvedStatus()).toHaveAttribute(
|
||||
'data-status',
|
||||
status,
|
||||
{
|
||||
timeout: STEP_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The answered card shows this outcome and names the place. */
|
||||
async function expectResolvedIn(n8n: n8nPage, status: string, place: string): Promise<void> {
|
||||
await expectResolved(n8n, status);
|
||||
await expect(n8n.experienceModes.getProposalResolvedStatus()).toContainText(place);
|
||||
}
|
||||
|
||||
function proposeScript(workflowId: string): ScriptInput {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
id: 'propose-automation',
|
||||
when: {
|
||||
systemIncludes: AGENT_PROMPT,
|
||||
userText: PROPOSAL_ASK,
|
||||
toolAvailable: 'propose_automation',
|
||||
},
|
||||
reply: {
|
||||
text: PROPOSAL_REPLY,
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'propose_automation',
|
||||
input: {
|
||||
workflowId,
|
||||
title: PROPOSAL_TITLE,
|
||||
why: ['It should run every morning.'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
fallback: { text: 'Scripted fallback.' },
|
||||
};
|
||||
}
|
||||
|
||||
function suspensionsOf(run: AssistantRun) {
|
||||
return run.events.flatMap((event) =>
|
||||
event.type === 'tool-call-suspended' ? [event.payload] : [],
|
||||
);
|
||||
}
|
||||
|
||||
async function findWorkflow(api: ApiHelpers, name: string): Promise<NamedWorkflow | undefined> {
|
||||
const workflows: NamedWorkflow[] = await api.workflows.getWorkflows();
|
||||
return workflows.find((workflow) => workflow.name === name);
|
||||
}
|
||||
|
||||
/** Waits until the other instance lists the workflow, then returns it. */
|
||||
async function waitForWorkflow(api: ApiHelpers, name: string): Promise<NamedWorkflow> {
|
||||
let found: NamedWorkflow | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
found = await findWorkflow(api, name);
|
||||
return found !== undefined;
|
||||
},
|
||||
{ timeout: STEP_TIMEOUT_MS },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!found) throw new TestError(`No workflow named "${name}"`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function at(x: number, y: number): [number, number] {
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function scheduleNode() {
|
||||
return {
|
||||
id: 'schedule',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: at(0, 0),
|
||||
parameters: { rule: { interval: [{ field: 'days', daysInterval: 1, triggerAtHour: 8 }] } },
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleWorkflow(name: string): Partial<IWorkflowBase> {
|
||||
return {
|
||||
name,
|
||||
active: false,
|
||||
nodes: [
|
||||
scheduleNode(),
|
||||
{
|
||||
id: 'noop',
|
||||
name: 'No Operation',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: at(240, 0),
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': { main: [[{ node: 'No Operation', type: 'main', index: 0 }]] },
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function credentialWorkflow(name: string, credentialId: string): Partial<IWorkflowBase> {
|
||||
return {
|
||||
name,
|
||||
active: false,
|
||||
nodes: [
|
||||
scheduleNode(),
|
||||
{
|
||||
id: 'http',
|
||||
name: 'Charge',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: at(240, 0),
|
||||
parameters: {
|
||||
url: 'https://example.test/charge',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'httpHeaderAuth',
|
||||
},
|
||||
credentials: { httpHeaderAuth: { id: credentialId, name: CREDENTIAL_NAME } },
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': { main: [[{ node: 'Charge', type: 'main', index: 0 }]] },
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function commandWorkflow(name: string): Partial<IWorkflowBase> {
|
||||
return {
|
||||
name,
|
||||
active: false,
|
||||
nodes: [
|
||||
scheduleNode(),
|
||||
{
|
||||
id: 'command',
|
||||
name: 'Run a command',
|
||||
type: 'n8n-nodes-base.executeCommand',
|
||||
typeVersion: 1,
|
||||
position: at(240, 0),
|
||||
parameters: { command: 'echo hello' },
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': { main: [[{ node: 'Run a command', type: 'main', index: 0 }]] },
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function manualWorkflow(name: string): Partial<IWorkflowBase> {
|
||||
return {
|
||||
name,
|
||||
active: false,
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: at(0, 0),
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function callingWorkflow(name: string, childId: string): Partial<IWorkflowBase> {
|
||||
return {
|
||||
name,
|
||||
active: false,
|
||||
nodes: [
|
||||
scheduleNode(),
|
||||
{
|
||||
id: 'call',
|
||||
name: 'Run the report',
|
||||
type: 'n8n-nodes-base.executeWorkflow',
|
||||
typeVersion: 1.2,
|
||||
position: at(240, 0),
|
||||
parameters: {
|
||||
source: 'database',
|
||||
workflowId: { __rl: true, mode: 'id', value: childId },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': { main: [[{ node: 'Run the report', type: 'main', index: 0 }]] },
|
||||
},
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import fs from 'fs/promises';
|
|||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { MAX_AGENT_CHAT_ATTACHMENT_BASE64_LENGTH } from '@n8n/api-types';
|
||||
|
||||
import { test, expect, instanceAiTestConfig, SKIP_PROXY_SETUP_ANNOTATION } from './fixtures';
|
||||
|
||||
/**
|
||||
|
|
@ -109,14 +111,15 @@ test.describe(
|
|||
{ annotation: [{ type: SKIP_PROXY_SETUP_ANNOTATION }] },
|
||||
async ({ n8n }) => {
|
||||
// The composer check is a convenience; the backend is authoritative. Post
|
||||
// straight to the endpoint to prove a scripted client cannot get past it.
|
||||
const response = await n8n.api.instanceAi.sendMessageResponse(crypto.randomUUID(), {
|
||||
// straight to the agents chat to prove a scripted client cannot get past it.
|
||||
const project = await n8n.api.projects.getMyPersonalProject();
|
||||
const response = await n8n.api.instanceAi.sendMessageResponse(project.id, {
|
||||
message: 'look at this',
|
||||
attachments: [
|
||||
{
|
||||
type: 'file',
|
||||
// One byte over the encoded ceiling of 10 MiB.
|
||||
data: 'A'.repeat(10 * 1024 * 1024 + 1),
|
||||
// One character over the encoded ceiling. The DTO rejects it
|
||||
// before the handler stores the file.
|
||||
data: 'A'.repeat(MAX_AGENT_CHAT_ATTACHMENT_BASE64_LENGTH + 1),
|
||||
mimeType: 'image/png',
|
||||
fileName: 'huge.png',
|
||||
},
|
||||
|
|
@ -125,9 +128,7 @@ test.describe(
|
|||
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.text();
|
||||
// Quotes the limit in the unit the user can act on, and says what to do.
|
||||
expect(body).toContain('7.5 MB');
|
||||
expect(body.toLowerCase()).toContain('smaller');
|
||||
expect(body).toContain('10 MB');
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user