From da90c0b3a369a1d7afedb3e37da23279803738e1 Mon Sep 17 00:00:00 2001 From: Swifty Date: Sun, 11 Oct 2026 09:06:23 +0200 Subject: [PATCH] test(editor): Cover linked automation proposals end to end (no-changelog) (#40835) Co-authored-by: Cursor Agent Co-authored-by: Swifty --- .github/workflows/ci-pull-requests.yml | 5 +- .../workflows/test-e2e-coverage-nightly.yml | 2 +- .github/workflows/test-e2e-pc-nightly.yml | 2 +- .../instance-ai/skills/code-mode/SKILL.md | 35 ++ .../skills/intent-recognition/SKILL.md | 7 +- .../__tests__/system-prompt.code-mode.test.ts | 9 + .../instance-ai/src/agent/system-prompt.ts | 2 + .../tools/orchestration/build-agent.tool.ts | 2 - .../src/events/maps/instance-ai.event-map.ts | 1 - .../instance-ai-metrics.service.test.ts | 2 - .../prometheus/instance-ai-metrics.service.ts | 4 - .../agents/__tests__/agent-sse-stream.test.ts | 18 + .../src/modules/agents/agent-sse-stream.ts | 12 + .../system-agent-execution.service.ts | 4 + .../instance-ai-tracing.service.test.ts | 6 +- .../__tests__/instance-ai.controller.test.ts | 2 + .../instance-ai/assistant-turn-options.ts | 19 +- .../instance-ai/instance-ai.controller.ts | 76 +++ .../cli/src/modules/instance-ai/onboarding.ts | 9 +- .../tracing/instance-ai-tracing.service.ts | 2 - .../frontend/@n8n/i18n/src/locales/en.json | 26 + .../AgentChatMessageAttachments.vue | 1 + .../components/AgentChatMessageList.vue | 50 +- .../agents/components/AgentChatPanel.vue | 20 +- .../agents/composables/useAgentChatStream.ts | 16 +- .../ai/instanceAi/InstanceAiEmptyView.vue | 75 ++- .../ai/instanceAi/InstanceAiThreadView.vue | 40 ++ .../__tests__/InstanceAiCodeModePanel.test.ts | 75 +++ .../__tests__/InstanceAiEmptyView.test.ts | 14 + .../createThreadComponentRenderer.ts | 2 - .../features/ai/instanceAi/approvalDetails.ts | 4 +- .../ai/instanceAi/codeMode/codeMode.test.ts | 15 + .../ai/instanceAi/codeMode/codeMode.ts | 83 +++ .../components/GatewayResourceDecision.vue | 5 +- .../InstanceAiAgentsConversation.vue | 230 ++++++- .../components/InstanceAiCodeModePanel.vue | 272 +++++++++ .../components/InstanceAiProjectHome.vue | 103 ++++ .../composables/useInstanceAiHandoff.ts | 1 - .../ai/instanceAi/homeMode/homeMode.test.ts | 18 + .../ai/instanceAi/homeMode/homeMode.ts | 20 + .../features/ai/shared/agentsChat/types.ts | 2 + .../quality/testing/playwright/package.json | 2 +- .../pages/components/experience-modes.ts | 60 ++ .../playwright/scripts/distribute-tests.mjs | 44 +- .../scripts/distribution-counter-reporter.ts | 17 +- .../scripts/local-linked-config.mjs | 4 +- .../scripts/local-linked-config.test.mjs | 2 +- .../testing/playwright/services/api-helper.ts | 20 + .../services/instance-ai-api-helper.ts | 13 +- .../playwright/tests/e2e/future-poc/README.md | 3 +- .../e2e/future-poc/automation-linked.spec.ts | 572 ++++++++++++++++++ .../instance-ai-oversized-attachments.spec.ts | 17 +- 52 files changed, 1961 insertions(+), 84 deletions(-) create mode 100644 packages/@n8n/instance-ai/skills/code-mode/SKILL.md create mode 100644 packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.code-mode.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCodeModePanel.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/codeMode/codeMode.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/codeMode/codeMode.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCodeModePanel.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiProjectHome.vue create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/homeMode/homeMode.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/homeMode/homeMode.ts create mode 100644 packages/quality/testing/playwright/tests/e2e/future-poc/automation-linked.spec.ts diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index e6dc7f6fff7..d36aa12d035 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -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 diff --git a/.github/workflows/test-e2e-coverage-nightly.yml b/.github/workflows/test-e2e-coverage-nightly.yml index c64c0f5f50e..acfa39259e8 100644 --- a/.github/workflows/test-e2e-coverage-nightly.yml +++ b/.github/workflows/test-e2e-coverage-nightly.yml @@ -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 diff --git a/.github/workflows/test-e2e-pc-nightly.yml b/.github/workflows/test-e2e-pc-nightly.yml index ae818847c39..62945c1133e 100644 --- a/.github/workflows/test-e2e-pc-nightly.yml +++ b/.github/workflows/test-e2e-pc-nightly.yml @@ -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 diff --git a/packages/@n8n/instance-ai/skills/code-mode/SKILL.md b/packages/@n8n/instance-ai/skills/code-mode/SKILL.md new file mode 100644 index 00000000000..7808f35622d --- /dev/null +++ b/packages/@n8n/instance-ai/skills/code-mode/SKILL.md @@ -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. diff --git a/packages/@n8n/instance-ai/skills/intent-recognition/SKILL.md b/packages/@n8n/instance-ai/skills/intent-recognition/SKILL.md index 5a307022239..f841fc1cb4d 100644 --- a/packages/@n8n/instance-ai/skills/intent-recognition/SKILL.md +++ b/packages/@n8n/instance-ai/skills/intent-recognition/SKILL.md @@ -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 diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.code-mode.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.code-mode.test.ts new file mode 100644 index 00000000000..1c61be660bd --- /dev/null +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.code-mode.test.ts @@ -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'); + }); +}); diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 47b6f63e35c..6b5333f6d39 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -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. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts index e05e87e0cd7..4b3b886460b 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/build-agent.tool.ts @@ -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') { diff --git a/packages/cli/src/events/maps/instance-ai.event-map.ts b/packages/cli/src/events/maps/instance-ai.event-map.ts index 4edd910dac9..977a44395bd 100644 --- a/packages/cli/src/events/maps/instance-ai.event-map.ts +++ b/packages/cli/src/events/maps/instance-ai.event-map.ts @@ -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. */ diff --git a/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts b/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts index 5ea61104185..d999e1dbea2 100644 --- a/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts +++ b/packages/cli/src/metrics/prometheus/__tests__/instance-ai-metrics.service.test.ts @@ -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', () => { diff --git a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts index 55aefed0d47..64cacba0b7c 100644 --- a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts @@ -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 }) => { diff --git a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts index 63da96a0cb8..4ef281dfd17 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts @@ -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 }]); diff --git a/packages/cli/src/modules/agents/agent-sse-stream.ts b/packages/cli/src/modules/agents/agent-sse-stream.ts index 5b62607bea3..5b118203a10 100644 --- a/packages/cli/src/modules/agents/agent-sse-stream.ts +++ b/packages/cli/src/modules/agents/agent-sse-stream.ts @@ -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; diff --git a/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts b/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts index 2a5c78cc4f9..5e37f9cd123 100644 --- a/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts +++ b/packages/cli/src/modules/agents/system-agents/system-agent-execution.service.ts @@ -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) => diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts index 7426c921665..84c9731f58d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts @@ -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(); }); }); - }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 24ff610aa74..18d046b2991 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -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(), ); const req = mock({ user: { id: USER_ID } }); diff --git a/packages/cli/src/modules/instance-ai/assistant-turn-options.ts b/packages/cli/src/modules/instance-ai/assistant-turn-options.ts index 3581d979ebb..309d8abfc90 100644 --- a/packages/cli/src/modules/instance-ai/assistant-turn-options.ts +++ b/packages/cli/src/modules/instance-ai/assistant-turn-options.ts @@ -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 { - return JSON.parse(JSON.stringify(options)) as Record; + // 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): void { + for (const key of Object.keys(record)) { + const value = record[key]; + if (value === undefined) { + delete record[key]; + continue; + } + if (isRecord(value)) omitUndefined(value); + } } diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 0aae8688458..435d597531c 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -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 { + 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 { + 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, diff --git a/packages/cli/src/modules/instance-ai/onboarding.ts b/packages/cli/src/modules/instance-ai/onboarding.ts index b0c6f08b793..a24b046d3d7 100644 --- a/packages/cli/src/modules/instance-ai/onboarding.ts +++ b/packages/cli/src/modules/instance-ai/onboarding.ts @@ -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; } - } diff --git a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts index e90b32c01cc..62f67dfa926 100644 --- a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts +++ b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts @@ -84,8 +84,6 @@ export class InstanceAiTracingService { private readonly eventReader: InstanceAiTracingEventReader; - - constructor(options: InstanceAiTracingServiceOptions) { this.logger = options.logger; this.eventReader = options.eventReader; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 55183357213..f7c4f9ef58a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue index 64de31446b7..837011d58c4 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageAttachments.vue @@ -114,6 +114,7 @@ onBeforeUnmount(() => { :download="item.href ? item.attachment.fileName : undefined" :class="$style.fileChip" :title="item.attachment.fileName" + data-test-id="chat-file" > {{ item.attachment.fileName }} diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index e554a016217..2254fef3354 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -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(
-
+
+ {{ i18n.baseText('instanceAi.error.outOfCredits.title') }} + + {{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }} + +
+
{{ group.message.content }}
+ +
+ {{ i18n.baseText('instanceAi.error.outOfCredits.title') }} + + {{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }} + +