mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
feat(editor): Add agents sidebar navigation and chat-first builder UX (#28127)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67321a075f
commit
aff4bf902d
326
.claude/specs/agent-16-and-1-home-and-creation.md
Normal file
326
.claude/specs/agent-16-and-1-home-and-creation.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# AGENT-16 + AGENT-1: Home Screen & Creation Flow
|
||||
|
||||
**Tickets:** [AGENT-16](https://linear.app/n8n/issue/AGENT-16), [AGENT-1](https://linear.app/n8n/issue/AGENT-1)
|
||||
**Branch:** `n8n-agents`
|
||||
**Figma:** https://www.figma.com/design/PeFCNtBUBdtDNonFc9vmqR/Agents-Q2?node-id=0-1&p=f&m=dev
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
These two tickets together transform the agent UX from the current code-editor-centric tabbed layout to the Figma-designed chat-first experience. AGENT-16 covers sidebar navigation and home screen entry points. AGENT-1 covers the agent creation flow and the main agent view (chat + settings sidebar).
|
||||
|
||||
---
|
||||
|
||||
## Part 1: AGENT-16 — Sidebar & Home Screen
|
||||
|
||||
### What Figma Shows
|
||||
|
||||
**Left sidebar** (see node `115:653`):
|
||||
- Below "Shared with me", a new **"Agents"** section header with **"New"** badge
|
||||
- Lists user's agents by name with emoji icons (e.g. "Darwin")
|
||||
- **"+ New agent"** action at bottom of list
|
||||
- Agents are visible regardless of which project is selected (cross-project)
|
||||
- Tooltip/coachmark on first visit: "Create agents that you can chat to, run autonomously, or include in workflows"
|
||||
|
||||
**Home screen** (Overview page):
|
||||
- "Create agent" available in the top-right **"Create workflow"** dropdown menu
|
||||
- Agents tab added to project tabs (Workflows, Credentials, Executions, Variables, **Agents**)
|
||||
|
||||
### What Already Exists
|
||||
|
||||
- `ProjectNavigation.vue` — main sidebar with Overview, Personal, Shared, InstanceAI, Chat items
|
||||
- `ProjectHeader.vue` — already has `ACTION_TYPES.AGENT` + "New Agent" button + dropdown item
|
||||
- `ProjectTabs.vue` — dynamic tabs via `additionalTabs` prop from module descriptors
|
||||
- `module.descriptor.ts` — agents module already registers "Agents" tab in `projectTabs.overview` and `projectTabs.project`
|
||||
- `settingsStore.isModuleActive('agents')` — feature flag gate already used in ProjectHeader
|
||||
- `listAllAgents` API — `GET /agents/v2?all=true` returns cross-project agent list
|
||||
|
||||
### What Needs to Change
|
||||
|
||||
#### 1.1 PostHog feature flag gating
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts`
|
||||
|
||||
The `agents` module registration should be gated behind PostHog flag `0XX_first_class_agents` (confirm index with team). When the flag is off, the module isn't registered, `settingsStore.isModuleActive('agents')` returns false, and all UI (sidebar, tabs, create button) is hidden.
|
||||
|
||||
#### 1.2 Add Agents section to `ProjectNavigation.vue`
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectNavigation.vue`
|
||||
|
||||
Add between the Instance AI / Chat items and the "Projects" label:
|
||||
|
||||
```
|
||||
"Agents" section header with "New" badge
|
||||
├── Agent 1 (icon + name) → navigates to agent builder
|
||||
├── Agent 2 (icon + name)
|
||||
└── + New agent → navigates to new agent creation page
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- Add computed `isAgentsAvailable` gated on `settingsStore.isModuleActive('agents')`
|
||||
- Add computed `agentsList` that calls `listAllAgents` (fetched on mount, cached in a local ref)
|
||||
- Render a section with `N8nText` header "Agents" + `N8nTag` "New" badge
|
||||
- Map agents to `N8nMenuItem` items with route to `AGENT_BUILDER_VIEW`
|
||||
- Add "+ New agent" item that routes to the new agent creation page (new route, see Part 2)
|
||||
- Active state: highlight when current route is an agent builder with matching agentId
|
||||
|
||||
#### 1.3 Coachmark tooltip
|
||||
|
||||
- On first display of the Agents section, show a tooltip/popover: "Create agents that you can chat to, run autonomously, or include in workflows"
|
||||
- Gate on a localStorage flag (e.g. `n8n_agents_coachmark_dismissed`)
|
||||
- Use existing `useN8nLocalStorage` composable for persistence
|
||||
- Dismiss on click or after navigating to an agent
|
||||
|
||||
#### 1.4 No changes needed (already done)
|
||||
|
||||
- "New Agent" in ProjectHeader create dropdown
|
||||
- Agents tab in ProjectTabs (via module descriptor)
|
||||
- Feature flag gating
|
||||
|
||||
---
|
||||
|
||||
## Part 2: AGENT-1 — Creation Flow & Agent View Redesign
|
||||
|
||||
### What Figma Shows
|
||||
|
||||
**A. "New agent" page** (nodes `115:1350`, `115:4136`):
|
||||
- Header: "New agent" title, **"Create blank"** button top-right
|
||||
- Center content: "Let's build something, [Name]" heading
|
||||
- Text input: "Describe what you're working on..." with mic + send buttons
|
||||
- Below input: **"Suggestions"** section with 3 template cards:
|
||||
- Each card: emoji icon + bold name + description + tool icons on right
|
||||
- e.g. "SEO Audit", "Recruiting Sourcer", "Inbox Sorter"
|
||||
- Submitting a description → uses the AI builder to create the agent (existing `POST /build` endpoint)
|
||||
|
||||
**B. Agent home/chat view** (node `6:193`):
|
||||
- **3-column layout**: left sidebar (app nav) | center (chat) | right (settings)
|
||||
- Top bar: agent icon + "Sales Agent" name, settings toggle + activity toggle icons
|
||||
- Center column:
|
||||
- Agent icon (large) + name + "Add a description here" subtitle
|
||||
- Chat input: "Send a message..." with attachment, mic, send buttons
|
||||
- **"Recent"** section: list of past sessions with source icon + title + preview + timestamp
|
||||
- Right column (**Settings sidebar**, toggled via icon):
|
||||
- "Settings" header with Cancel + Save buttons
|
||||
- "Unsaved changes" indicator
|
||||
- **Model**: provider icon + model name + credential, with dropdown
|
||||
- **Instructions**: text area with agent system prompt
|
||||
- **Triggers** (collapsible): n8n Chat, Slack Mention, Schedule, Workflow — with "+" to add
|
||||
- **Tools** (collapsible): list of tools with credential names, "+" to add, warning state for missing creds
|
||||
- **Advanced** (collapsible): memory config, guardrails toggles, delete agent
|
||||
|
||||
**C. Agent chat conversation** (node `14:319`):
|
||||
- Breadcrumb: "Sales Agent > Biggest churn risk"
|
||||
- User messages right-aligned, agent messages left-aligned
|
||||
- Tool calls: collapsible "5 tool calls" with list (icon + "Service → Action description")
|
||||
- Message actions: copy, retry, more (...)
|
||||
- Chat input pinned to bottom
|
||||
|
||||
### What Already Exists
|
||||
|
||||
- `AgentBuilderView.vue` — current tabbed layout: left sidebar (Code/Overview/Tools/Prompts/Memory/Evals/Integrations) + center panel + right chat panel
|
||||
- `AgentChatPanel.vue` — SSE streaming chat with tool call display (functional)
|
||||
- `AgentOverviewPanel.vue` — model selector, credential picker, instructions editor
|
||||
- `AgentToolsPanel.vue` — tool management with workflow tools
|
||||
- `AgentSidebar.vue` — left tab navigation (code-first tabs)
|
||||
- All backend APIs for chat, schema, tools, integrations already exist
|
||||
|
||||
### What Needs to Change
|
||||
|
||||
#### 2.1 New route: Agent creation page
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/module.descriptor.ts`
|
||||
|
||||
Add a new route:
|
||||
```typescript
|
||||
{
|
||||
name: 'NEW_AGENT',
|
||||
path: '/new-agent',
|
||||
component: NewAgentView,
|
||||
meta: { middleware: ['authenticated', 'custom'] },
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/constants.ts`
|
||||
|
||||
Add: `export const NEW_AGENT_VIEW = 'new-agent';`
|
||||
|
||||
#### 2.2 New view: `NewAgentView.vue`
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/views/NewAgentView.vue`
|
||||
|
||||
Layout:
|
||||
- Uses the standard app layout (with main sidebar visible)
|
||||
- Top bar: "New agent" title left, "Create blank" button right
|
||||
- Center content (vertically centered, max-width ~600px):
|
||||
- Heading: "Let's build something, [firstName]" (from `useUsersStore().currentUser`)
|
||||
- Text area input: placeholder "Describe what you're working on...", mic button, send button
|
||||
- "Suggestions" label
|
||||
- 3 suggestion cards (hardcoded for P0, ties into AGENT-15):
|
||||
- Icon + name (bold) + description + tool count icons
|
||||
- Click → populates the text input with a pre-written prompt
|
||||
|
||||
Behavior:
|
||||
- **"Create blank"** button: calls `createAgent()` API, navigates to `AGENT_BUILDER_VIEW`
|
||||
- **Submit description**: calls `createAgent()`, then navigates to `AGENT_BUILDER_VIEW` with a query param like `?prompt=...` so the builder can auto-trigger the build endpoint
|
||||
- **Click suggestion**: populates input, user can edit + submit
|
||||
|
||||
#### 2.3 Redesign `AgentBuilderView.vue` — chat-first layout
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue`
|
||||
|
||||
Current layout:
|
||||
```
|
||||
[AgentSidebar (tabs)] [Center (code/overview/tools/...)] [AgentChatPanel]
|
||||
```
|
||||
|
||||
New layout (matching Figma):
|
||||
```
|
||||
[Center: Agent home + Chat] [Settings sidebar (togglable)]
|
||||
```
|
||||
|
||||
Changes:
|
||||
- Remove `AgentSidebar` (tab navigation) from the agent builder view
|
||||
- Center column becomes the **chat-first view**:
|
||||
- Agent header: icon + name (editable) + description (editable)
|
||||
- Chat input (reuse `AgentChatPanel` internals or `ChatInputBase`)
|
||||
- Recent sessions list (below chat input when no conversation is active)
|
||||
- When in conversation: full chat thread replaces the home content
|
||||
- Right column becomes the **Settings sidebar** (new component):
|
||||
- Toggle visibility via header icons (settings icon + activity icon)
|
||||
- Scrollable panel with all settings sections
|
||||
|
||||
Top bar:
|
||||
- Left: agent icon + agent name (as breadcrumb: "Sales Agent" or "Sales Agent > Session title")
|
||||
- Right: settings toggle icon, activity toggle icon
|
||||
|
||||
#### 2.4 New component: `AgentSettingsSidebar.vue`
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/components/AgentSettingsSidebar.vue`
|
||||
|
||||
A right-hand panel (~340px wide) containing all agent configuration in a single scrollable view:
|
||||
|
||||
| Section | Content | Existing component to reuse |
|
||||
|---------|---------|---------------------------|
|
||||
| Header | "Settings" + Cancel/Save buttons + "Unsaved changes" | New |
|
||||
| Model | Provider + model dropdown + credential picker | `AgentOverviewPanel` (extract model/credential parts) |
|
||||
| Instructions | Text area for system prompt | `AgentOverviewPanel` (extract instructions part) |
|
||||
| Triggers | Collapsible list + "+" button | New (P0: just list, no inline config) |
|
||||
| Tools | Collapsible list with credential status + "+" button | `AgentToolsPanel` (simplified view) |
|
||||
| Advanced | Collapsible: memory, guardrails, delete | `AgentMemoryPanel` parts, new guardrails toggles |
|
||||
| Code | Collapsible accordion (collapsed by default), Monaco editor | `AgentCodeEditor` (existing) |
|
||||
|
||||
Behavior:
|
||||
- Tracks local changes, shows "Unsaved changes" indicator
|
||||
- Save: calls `patchAgentSchema()` + `updateAgent()` as needed
|
||||
- Cancel: reverts to last-saved state
|
||||
|
||||
#### 2.5 New component: `AgentHomeContent.vue`
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/agents/components/AgentHomeContent.vue`
|
||||
|
||||
The center content when no chat session is active:
|
||||
- Large agent icon (centered)
|
||||
- Agent name (editable on click)
|
||||
- "Add a description here" subtitle (editable on click)
|
||||
- Chat input (`ChatInputBase` with attachment, mic, send)
|
||||
- "Recent" section header with list/grid toggle icon
|
||||
- Recent sessions list (each item: source icon + title + preview text + timestamp)
|
||||
|
||||
Note: Recent sessions requires a new API endpoint. For P0 MVP, this can show an empty state or be omitted if the sessions API isn't ready (depends on AGENT-9).
|
||||
|
||||
#### 2.6 Update `AgentChatPanel.vue` for inline use
|
||||
|
||||
Currently `AgentChatPanel` is a right-side panel. It needs to work as the center content when a conversation is active. Refactor to:
|
||||
- Extract chat message thread into a reusable `AgentChatThread.vue`
|
||||
- `AgentChatPanel` becomes a wrapper that can render in center-column mode (full width) vs. the old side-panel mode
|
||||
- Add breadcrumb support in the top bar (agent name > session title)
|
||||
|
||||
#### 2.7 Update `ProjectHeader.vue` — "New Agent" action
|
||||
|
||||
Currently "New Agent" in the dropdown creates a blank agent and navigates to builder. Change to navigate to `NEW_AGENT_VIEW` instead, so users see the creation flow.
|
||||
|
||||
**File:** `packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue`
|
||||
|
||||
```typescript
|
||||
[ACTION_TYPES.AGENT]: () => {
|
||||
void router.push({ name: NEW_AGENT_VIEW });
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| Modify | `ProjectNavigation.vue` | Add Agents section with list + coachmark |
|
||||
| Create | `views/NewAgentView.vue` | "Let's build something" creation page |
|
||||
| Create | `components/AgentSettingsSidebar.vue` | Right-hand settings panel |
|
||||
| Create | `components/AgentHomeContent.vue` | Agent home (icon, chat input, recent) |
|
||||
| Modify | `views/AgentBuilderView.vue` | Redesign to chat-first 2-column layout |
|
||||
| Modify | `components/AgentChatPanel.vue` | Refactor for center-column use |
|
||||
| Modify | `module.descriptor.ts` | Add NEW_AGENT route |
|
||||
| Modify | `constants.ts` | Add NEW_AGENT_VIEW constant |
|
||||
| Modify | `ProjectHeader.vue` | Route "New Agent" to creation page |
|
||||
| Modify | `AgentOverviewPanel.vue` | Extract model/instructions for reuse in settings sidebar |
|
||||
|
||||
---
|
||||
|
||||
## Implementation TODO
|
||||
|
||||
### AGENT-16: Sidebar & Home Screen
|
||||
- [ ] Gate agents module registration behind PostHog flag `0XX_first_class_agents` in `moduleInitializer.ts`
|
||||
- [x] Add `isAgentsAvailable` computed + agent list fetch to `ProjectNavigation.vue`
|
||||
- [x] Render Agents section header with "New" badge
|
||||
- [x] Render agent list items with icons + names + navigation (global, cross-project)
|
||||
- [x] Add "+ New agent" item routing to creation page
|
||||
- [x] Add coachmark tooltip with localStorage dismissal
|
||||
- [x] Active state highlighting for current agent
|
||||
|
||||
### AGENT-1: Creation Flow
|
||||
- [x] Add `NEW_AGENT_VIEW` constant and route in module descriptor
|
||||
- [x] Create `NewAgentView.vue` with heading, input, suggestions, "Create blank"
|
||||
- [x] Hardcode 3 suggestion templates (SEO Audit, Recruiting Sourcer, Inbox Sorter)
|
||||
- [x] "Create blank" creates agent + navigates to builder
|
||||
- [x] Submit description creates agent + navigates to builder with prompt param
|
||||
- [x] Update ProjectHeader to route "New Agent" action to creation page
|
||||
|
||||
### AGENT-1: Agent View Redesign
|
||||
- [x] Create `AgentSettingsSidebar.vue` with Model, Instructions, Triggers, Tools, Advanced, Code sections
|
||||
- [x] Code section: collapsible accordion (collapsed by default) wrapping `AgentCodeEditor`
|
||||
- [x] Create `AgentHomeContent.vue` with agent icon/name/description + chat input + recent sessions placeholder
|
||||
- [x] Recent sessions: show "No conversations yet" empty state
|
||||
- [x] Redesign `AgentBuilderView.vue` to 2-column layout (center chat + right settings)
|
||||
- [x] Add top bar with agent name, settings toggle, activity toggle — split header pattern (each column has own 56px header)
|
||||
- [x] Refactor `AgentChatPanel.vue` for center-column rendering (inline mode)
|
||||
- [x] ~~Extract reusable parts from `AgentOverviewPanel`~~ → Built model/instructions/credential directly in sidebar using ChatHub patterns (CredentialIcon, PROVIDER_CREDENTIAL_TYPE_MAP, credential setup flow)
|
||||
- [x] Wire Save/Cancel in settings sidebar to schema API
|
||||
- [x] Add "Unsaved changes" tracking (dirty state lifted to parent view)
|
||||
|
||||
### Additional work completed (not in original plan)
|
||||
- [x] Dynamic provider list from `chatHubLLMProviderSchema` (not hardcoded)
|
||||
- [x] Provider icons via `CredentialIcon` + `PROVIDER_CREDENTIAL_TYPE_MAP` (not copied SVGs)
|
||||
- [x] ChatHub-style credential setup: auto-select single credential, open selector modal or creation modal
|
||||
- [x] Model dropdown disabled when no credential is set
|
||||
- [x] Provider ↔ catalog ID mapping (ChatHub camelCase ↔ SDK lowercase)
|
||||
- [x] All static text uses i18n (30 keys in en.json)
|
||||
- [x] CSS uses proper design tokens (`--background--surface`, `--background--surface--hover`)
|
||||
- [x] Unit tests: constants, provider mapping, NewAgentView render
|
||||
|
||||
### Deferred (not blocking P0 launch)
|
||||
- [ ] Recent sessions list (needs AGENT-9 execution logging API)
|
||||
- [ ] Activity sidebar (Agent ID, Session ID, context, cost)
|
||||
- [ ] Trigger inline configuration (n8n Chat, Schedule — Slack already works)
|
||||
- [ ] Tool config modals (Slack channels, Workflow config)
|
||||
- [ ] Breadcrumb navigation for chat sessions
|
||||
- [ ] PostHog feature flag gating (needs backend + flag index confirmed with team)
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Agents in sidebar — global.** The sidebar shows all agents across all projects via `?all=true` API. The "Agents" tab within a project view shows only that project's agents (project-scoped via `listAgents`).
|
||||
2. **Feature flag — PostHog `0XX_first_class_agents`.** A PostHog experiment flag controls whether the `agents` module is registered in `moduleInitializer.ts`. The `settingsStore.isModuleActive('agents')` gate stays as the UI-level check — it'll be falsy when the module isn't registered. The exact index prefix (e.g. `025`, `026`) should be confirmed with the team before implementation.
|
||||
3. **Recent sessions — placeholder.** Show the "Recent" section header with an empty state: "No conversations yet — send a message to get started." This preserves layout structure and will be wired to real data when AGENT-9 lands.
|
||||
4. **Code editor — collapsed accordion.** Keep the Monaco code editor accessible in the settings sidebar as a collapsible "Code" accordion section, collapsed by default. Will be hidden/removed closer to P0 release.
|
||||
169
.claude/specs/agents-q2-scope.md
Normal file
169
.claude/specs/agents-q2-scope.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Agents Q2 — Project Scope
|
||||
|
||||
**Branch:** `n8n-agents`
|
||||
**Linear:** https://linear.app/n8n/project/agents-as-first-class-citizen-3bfa6b1e2bb8
|
||||
**Figma:** https://www.figma.com/design/PeFCNtBUBdtDNonFc9vmqR/Agents-Q2?node-id=0-1&p=f&m=dev
|
||||
**Target:** 2026-04-27
|
||||
**Team:** Agent (Yehor Kardash lead)
|
||||
|
||||
## P0 Objectives
|
||||
|
||||
- Learn what agents users build to expand framework capabilities
|
||||
- Nail down agent building UX
|
||||
- MVP: create agents as first-class citizens, connect workflows as tools, invoke from within (workflows) and outside (chat) n8n
|
||||
- Release: experiment to 5% cloud users (and/or self-hosted)
|
||||
|
||||
---
|
||||
|
||||
## P0 Linear Tickets
|
||||
|
||||
| Ticket | Title | Status | Assignee | Branch impl? |
|
||||
|--------|-------|--------|----------|--------------|
|
||||
| AGENT-1 | Creation flow UI | Ready for pick up | — | Partial (list + builder views exist, but not Figma design) |
|
||||
| AGENT-2 | Integrate agent builder with InstanceAI | Ready for pick up | — | Partial (BuilderCard exists) |
|
||||
| AGENT-3 | Agent <-> JSON conversion | Review | Yehor | Done (schema introspection + fromSchema + code gen) |
|
||||
| AGENT-4 | Run agent code in sandbox for JSON schema | Review | Michael Drury | Done (AgentSecureRuntime + describeSecurely) |
|
||||
| AGENT-5 | Allow running nodes as tools | In Progress | Yehor | Not yet |
|
||||
| AGENT-6 | Allow running workflows as tools | Review | Yehor | Done (WorkflowToolFactory) |
|
||||
| AGENT-7 | Chat integrations and triggers | Ready for pick up | — | Partial (Slack done, n8n Chat + Schedule not yet) |
|
||||
| AGENT-8 | Default agent memory | In Progress | Yehor | Partial (N8NCheckpointStorage exists, full N8nMemory TBD) |
|
||||
| AGENT-9 | Agent execution logging | Ready for pick up | — | Not yet |
|
||||
| AGENT-10 | Support top N LLM providers | Ready for pick up | — | Partial (model catalog API exists, UI picker exists) |
|
||||
| AGENT-11 | Top N memory integrations (Postgres, Redis) | Ready for pick up | — | Not yet |
|
||||
| AGENT-12 | Agent evals | Ready for pick up | — | Partial (UI panel exists, backend eval runner TBD) |
|
||||
| AGENT-13 | Create Agent node (workflow) | Ready for pick up | — | Not yet |
|
||||
| AGENT-14 | "create-tool" tool | Ready for pick up | — | Not yet |
|
||||
| AGENT-15 | Hardcoded starter templates | Ready for pick up | — | Not yet |
|
||||
| AGENT-16 | Update home screen | Ready for pick up | — | Partial (agents tab in project header, not full sidebar) |
|
||||
| AGENT-17 | Memory analysis / footprint | Parked | — | Not yet |
|
||||
|
||||
**Progress: ~7% (per Linear milestone)**
|
||||
|
||||
---
|
||||
|
||||
## Full Figma Scope (3 sections)
|
||||
|
||||
### 1. Using the Agent
|
||||
- Left sidebar: "Agents" section with list + "+ New agent"
|
||||
- Agent home: icon, name, description, chat input, recent sessions with source icons + timestamps
|
||||
- Chat view: breadcrumb nav, user/agent messages, collapsible tool calls, message actions (copy/retry/more)
|
||||
- Settings sidebar (right panel): Model picker, instructions, triggers (n8n Chat/Slack/Schedule), tools list with credentials, advanced section, cancel/save with unsaved indicator
|
||||
- Activity sidebar: Agent ID, Session ID, model, context usage (tokens/%), cost
|
||||
- Tools modal: search, connected tools with status, available tools catalog, per-tool gear
|
||||
- Tool config modals: Slack (credential + channel picker), Workflow (workflow + trigger + message), generic pattern
|
||||
- Workflow node: "Custom Agent" node in canvas with agent dropdown + prompt
|
||||
|
||||
### 2. Creating an Agent
|
||||
- Via InstanceAI: chat-based creation, progress steps, agent card on completion
|
||||
- Via "+ New agent": "Let's build something" landing, description input, suggestion cards (SEO Audit, Recruiting Sourcer, etc.), "Create blank" option
|
||||
|
||||
### 3. View Agent Session (WIP in Figma)
|
||||
- Read-only session review with full tool call history
|
||||
|
||||
---
|
||||
|
||||
## What's Been Implemented
|
||||
|
||||
### Backend (`packages/cli/src/modules/agents/`)
|
||||
|
||||
**DB:** `agents` table (id, name, code, description, projectId, credentialId, provider, model, integrations JSON, schema JSON) + `agent_checkpoints` table
|
||||
|
||||
**API** (`/projects/:projectId/agents/v2`):
|
||||
- CRUD: POST/GET/PATCH/DELETE agents
|
||||
- GET `/:agentId/schema` / PATCH `/:agentId/schema` — introspect + update
|
||||
- GET `/:agentId/credentials` — list user credentials
|
||||
- GET `/catalog/models` — LLM provider/model catalog
|
||||
- POST `/:agentId/chat` — SSE streaming chat execution
|
||||
- POST `/:agentId/build` — SSE streaming AI builder (code generation)
|
||||
- POST `/:agentId/integrations/connect|disconnect`, GET `/status` — Slack integration
|
||||
- POST `/:agentId/webhooks/:platform` — inbound Slack webhooks
|
||||
|
||||
**Services:**
|
||||
- `AgentsService` — CRUD, runtime caching (TTL 30m), schema caching (TTL 10m), `executeForChat()`, `resumeForChat()`, `compileIsolated()`
|
||||
- `AgentsBuilderService` — AI builds/modifies agent code via streaming
|
||||
- `AgentSecureRuntime` — sandboxed user code execution
|
||||
- `WorkflowToolFactory` — n8n workflows as agent tools (chat/manual/schedule/form/executeWorkflow triggers)
|
||||
- `ChatIntegrationService` — Slack Chat SDK lifecycle, webhook routing
|
||||
- `AgentChatBridge` — Chat SDK mentions → agent execution, rich interactions
|
||||
- `ComponentMapper` — tool-call suspensions → Slack Block Kit
|
||||
- `N8NCheckpointStorage` — conversation state persistence
|
||||
- `generate-agent-code.ts` — schema ↔ code round-trip
|
||||
|
||||
### SDK (`packages/@n8n/agents/`)
|
||||
- `Agent.fromSchema()` — reconstruct agent from persisted schema
|
||||
- Schema introspection (describe agent → `AgentSchema`)
|
||||
- Provider capabilities / model catalog
|
||||
- Credential provider interface
|
||||
|
||||
### Frontend (`packages/frontend/editor-ui/src/features/agents/`)
|
||||
|
||||
**Routes:** `/home/agents`, `/projects/:projectId/agents`, `/projects/:projectId/agents/:agentId`
|
||||
|
||||
**Views:**
|
||||
- `AgentsListView` — list with sort/create/delete
|
||||
- `AgentBuilderView` — tabbed builder (code, overview, tools, prompts, memory, evals, integrations) + chat panel
|
||||
|
||||
**Components:**
|
||||
- `AgentChatPanel` — SSE streaming chat with tool call display
|
||||
- `AgentCodeEditor` — Monaco TypeScript editor
|
||||
- `AgentOverviewPanel` — model/credential/instructions/description
|
||||
- `AgentToolsPanel` — add/remove/configure tools + workflow tools
|
||||
- `AgentPromptsPanel` — system prompt editing
|
||||
- `AgentMemoryPanel` — memory config (last messages, semantic recall, working memory)
|
||||
- `AgentEvalsPanel` — evaluation setup
|
||||
- `AgentIntegrationsPanel` — Slack connect/disconnect
|
||||
- `AgentSidebar` — tab navigation
|
||||
- `AgentListItem`, `AgentMiniEditor`
|
||||
|
||||
**API composables:** `useAgentApi`, `useAgentSchema`, `useModelCatalog`
|
||||
|
||||
### Other
|
||||
- InstanceAI: `BuilderCard` for agent creation in InstanceAI chat
|
||||
- `ProjectHeader` modified for agents tab
|
||||
- Slack OAuth2 credential updates
|
||||
- `@n8n/config` agents config
|
||||
- `@n8n/decorators` route options for SSE/webhooks
|
||||
|
||||
---
|
||||
|
||||
## P0 Gap Analysis — What's Left
|
||||
|
||||
### Must Build
|
||||
|
||||
| Area | Tickets | Details |
|
||||
|------|---------|---------|
|
||||
| **Creation flow UI** | AGENT-1, AGENT-16 | "New agent" landing page, sidebar nav with agents section, agent home screen (chat-first) |
|
||||
| **Nodes as tools** | AGENT-5 | Run n8n nodes as agent tools via @n8n/workflow-sdk |
|
||||
| **Chat integrations + triggers** | AGENT-7 | n8n Chat trigger, Schedule trigger (Slack already done) |
|
||||
| **Default memory** | AGENT-8 | Full N8nMemory for DB-backed memory (checkpoint storage exists but not full memory) |
|
||||
| **Execution logging** | AGENT-9 | Agent execution history, similar to workflow executions |
|
||||
| **LLM providers** | AGENT-10 | Expand provider support beyond current catalog |
|
||||
| **Agent node** | AGENT-13 | "Custom Agent" workflow node: agent dropdown + prompt |
|
||||
| **"create-tool" tool** | AGENT-14 | Search tool registry, create tools on the fly |
|
||||
| **Starter templates** | AGENT-15 | Hardcoded agent templates for onboarding |
|
||||
| **InstanceAI integration** | AGENT-2 | Full InstanceAI subagent for agent creation |
|
||||
|
||||
### Lower Priority P0
|
||||
|
||||
| Area | Tickets | Details |
|
||||
|------|---------|---------|
|
||||
| **Memory integrations** | AGENT-11 | Postgres, Redis, etc. memory backends |
|
||||
| **Evals** | AGENT-12 | Agent evaluation framework |
|
||||
| **Memory footprint** | AGENT-17 | Performance analysis (parked) |
|
||||
|
||||
### In Review (nearly done)
|
||||
|
||||
| Tickets | Details |
|
||||
|---------|---------|
|
||||
| AGENT-3 | Agent <-> JSON conversion (schema introspection, fromSchema, code gen) |
|
||||
| AGENT-4 | Sandbox schema generation (AgentSecureRuntime) |
|
||||
| AGENT-6 | Workflows as tools (WorkflowToolFactory) |
|
||||
|
||||
### Already Solid (no major changes needed)
|
||||
|
||||
- Backend CRUD + streaming API
|
||||
- Agent SDK (schema introspection, fromSchema, code generation)
|
||||
- Slack integration plumbing (webhooks, Chat SDK bridge, checkpoint storage)
|
||||
- Workflow-as-tool resolution
|
||||
- AI builder (streaming code generation)
|
||||
- Chat execution with tool calls
|
||||
|
|
@ -160,6 +160,7 @@ import IconLucideMaximize from '~icons/lucide/maximize';
|
|||
import IconLucideMaximize2 from '~icons/lucide/maximize-2';
|
||||
import IconLucideMenu from '~icons/lucide/menu';
|
||||
import IconLucideMessageCircle from '~icons/lucide/message-circle';
|
||||
import IconLucideMessageCirclePlus from '~icons/lucide/message-circle-plus';
|
||||
import IconLucideMessageSquare from '~icons/lucide/message-square';
|
||||
import IconLucideMessageSquarePlus from '~icons/lucide/message-square-plus';
|
||||
import IconLucideMessagesSquare from '~icons/lucide/messages-square';
|
||||
|
|
@ -628,6 +629,7 @@ export const updatedIconSet = {
|
|||
'message-circle': IconLucideMessageCircle,
|
||||
'message-square': IconLucideMessageSquare,
|
||||
'message-square-plus': IconLucideMessageSquarePlus,
|
||||
'message-circle-plus': IconLucideMessageCirclePlus,
|
||||
'messages-square': IconLucideMessagesSquare,
|
||||
mic: IconLucideMic,
|
||||
milestone: IconLucideMilestone,
|
||||
|
|
|
|||
|
|
@ -40,8 +40,12 @@ const emojiRanges = [
|
|||
|
||||
type Props = {
|
||||
buttonTooltip: string;
|
||||
buttonSize?: 'small' | 'large';
|
||||
buttonSize?: 'small' | 'large' | 'xlarge';
|
||||
isReadOnly?: boolean;
|
||||
/** Additional CSS class(es) for the outer container element */
|
||||
containerClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>;
|
||||
/** Additional CSS class(es) for the trigger button */
|
||||
buttonClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -166,11 +170,14 @@ async function loadEmojiMetadataMap() {
|
|||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="{
|
||||
[$style.container]: true,
|
||||
[$style.isReadOnly]: isReadOnly,
|
||||
[$style[props.buttonSize]]: true,
|
||||
}"
|
||||
:class="[
|
||||
{
|
||||
[$style.container]: true,
|
||||
[$style.isReadOnly]: isReadOnly,
|
||||
[$style[props.buttonSize]]: true,
|
||||
},
|
||||
containerClass,
|
||||
]"
|
||||
:aria-expanded="popupVisible"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
|
|
@ -182,7 +189,7 @@ async function loadEmojiMetadataMap() {
|
|||
</template>
|
||||
<N8nIconButton
|
||||
v-if="model.type === 'icon'"
|
||||
:class="$style['icon-button']"
|
||||
:class="[$style['icon-button'], buttonClass]"
|
||||
:icon="model.value"
|
||||
:size="buttonSize"
|
||||
icon-only
|
||||
|
|
@ -194,7 +201,7 @@ async function loadEmojiMetadataMap() {
|
|||
/>
|
||||
<N8nButton
|
||||
v-else-if="model.type === 'emoji'"
|
||||
:class="$style['emoji-button']"
|
||||
:class="[$style['emoji-button'], buttonClass]"
|
||||
:size="buttonSize"
|
||||
icon-only
|
||||
variant="subtle"
|
||||
|
|
@ -299,6 +306,16 @@ async function loadEmojiMetadataMap() {
|
|||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.xlarge & {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.xxlarge & {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
|
|
@ -308,6 +325,14 @@ async function loadEmojiMetadataMap() {
|
|||
.small & {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.xlarge & {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.xxlarge & {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.popup {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as N8nActionBox } from './N8nActionBox';
|
||||
export { default as BetaTag } from './BetaTag/BetaTag.vue';
|
||||
export { default as N8nAskAssistantButton } from './AskAssistantButton';
|
||||
export {
|
||||
default as N8nAskAssistantChat,
|
||||
|
|
|
|||
|
|
@ -5424,6 +5424,45 @@
|
|||
"instanceAi.filesystem.category.shell": "Shell",
|
||||
"instanceAi.settings.permissions.activateWorkflow.label": "Activate workflow without approval",
|
||||
"instanceAi.tools.patch-workflow": "Updating workflow",
|
||||
"agents.sidebar.label": "Agents",
|
||||
"agents.sidebar.coachmark": "Create agents that you can chat to, run autonomously, or include in workflows",
|
||||
"agents.sidebar.coachmark.dismiss": "Got it",
|
||||
"agents.sidebar.newAgent": "New agent",
|
||||
"agents.newAgent.title": "New agent",
|
||||
"agents.newAgent.createBlank": "Create blank",
|
||||
"agents.newAgent.heading": "Let's build something{name}",
|
||||
"agents.newAgent.placeholder": "Describe what you're working on...",
|
||||
"agents.newAgent.suggestions": "Suggestions",
|
||||
"agents.home.iconPicker.tooltip": "Change agent icon",
|
||||
"agents.home.untitledAgent": "Untitled Agent",
|
||||
"agents.home.addDescription": "Add a description here",
|
||||
"agents.home.sendMessage": "Send a message...",
|
||||
"agents.home.recent": "Recent",
|
||||
"agents.home.emptyState": "No conversations yet — send a message to get started.",
|
||||
"agents.settings.title": "Settings",
|
||||
"agents.settings.cancel": "Cancel",
|
||||
"agents.settings.save": "Save",
|
||||
"agents.settings.unsavedChanges": "Unsaved changes",
|
||||
"agents.settings.triggers": "Triggers",
|
||||
"agents.settings.triggers.placeholder": "Trigger configuration will be available soon.",
|
||||
"agents.settings.tools": "Tools",
|
||||
"agents.settings.advanced": "Advanced",
|
||||
"agents.settings.code": "Code",
|
||||
"agents.settings.model": "Model",
|
||||
"agents.settings.model.selectModel": "Select model...",
|
||||
"agents.settings.model.selectProvider": "Provider...",
|
||||
"agents.settings.model.selectCredential": "Select credential...",
|
||||
"agents.settings.instructions": "Instructions",
|
||||
"agents.settings.instructions.placeholder": "Enter system instructions for this agent...",
|
||||
"agents.settings.model.setupCredential": "Set up credentials",
|
||||
"agents.settings.model.credentialRequired": "Set up credentials to select a model",
|
||||
"agents.builder.untitledAgent": "Untitled Agent",
|
||||
"agents.builder.saving": "Saving…",
|
||||
"agents.builder.save": "Save",
|
||||
"agents.builder.deleteAgent": "Delete agent",
|
||||
"agents.builder.deleteConfirmMessage": "Are you sure you want to delete \"{name}\"?",
|
||||
"agents.builder.deleteConfirmButton": "Delete",
|
||||
"agents.builder.deleteCancelButton": "Cancel",
|
||||
"instanceAi.welcomeModal.badge": "Early preview",
|
||||
"instanceAi.welcomeModal.title": "Try AI Assistant",
|
||||
"instanceAi.welcomeModal.description": "An agent that can modify workflows, credentials and<br>connect services on your behalf",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: { projectId: 'test-project', agentId: 'test-agent' }, query: {} }),
|
||||
RouterLink: { template: '<a><slot/></a>' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'agents.home.untitledAgent': 'Untitled Agent',
|
||||
'agents.home.addDescription': 'Add a description here',
|
||||
'agents.home.sendMessage': 'Send a message...',
|
||||
'agents.home.recent': 'Recent',
|
||||
'agents.home.emptyState': 'No conversations yet',
|
||||
'agents.home.iconPicker.tooltip': 'Change agent icon',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('is-emoji-supported', () => ({
|
||||
isEmojiSupported: () => true,
|
||||
}));
|
||||
|
||||
describe('AgentHomeContent', () => {
|
||||
async function renderComponent(props: Partial<Record<string, unknown>> = {}) {
|
||||
const { default: AgentHomeContent } = await import('../components/AgentHomeContent.vue');
|
||||
return mount(AgentHomeContent, {
|
||||
props: {
|
||||
agentName: 'Test Agent',
|
||||
agentDescription: 'A test agent description',
|
||||
agentIcon: { type: 'icon', value: 'robot' },
|
||||
projectId: 'test-project',
|
||||
agentId: 'test-agent',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ChatInputBase: { template: '<div data-testid="chat-input-stub" />' },
|
||||
N8nIconPicker: { template: '<div data-testid="icon-picker-stub" />' },
|
||||
N8nText: {
|
||||
template: '<span v-bind="$attrs"><slot/></span>',
|
||||
props: ['size', 'color', 'tag', 'bold'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the agent name', async () => {
|
||||
const wrapper = await renderComponent({ agentName: 'Test Agent' });
|
||||
const heading = wrapper.find('h2');
|
||||
expect(heading.text()).toBe('Test Agent');
|
||||
}, 15_000);
|
||||
|
||||
it('renders "Untitled Agent" when name is empty', async () => {
|
||||
const wrapper = await renderComponent({ agentName: '' });
|
||||
expect(wrapper.text()).toContain('Untitled Agent');
|
||||
});
|
||||
|
||||
it('renders the agent description', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.text()).toContain('A test agent description');
|
||||
});
|
||||
|
||||
it('renders placeholder when description is null', async () => {
|
||||
const wrapper = await renderComponent({ agentDescription: null });
|
||||
expect(wrapper.text()).toContain('Add a description here');
|
||||
});
|
||||
|
||||
it('enters name editing mode on click', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
const heading = wrapper.find('h2');
|
||||
await heading.trigger('click');
|
||||
const input = wrapper.find('input');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).value).toBe('Test Agent');
|
||||
});
|
||||
|
||||
it('emits update:name on Enter', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
await wrapper.find('h2').trigger('click');
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('New Name');
|
||||
await input.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.emitted('update:name')?.[0]).toEqual(['New Name']);
|
||||
});
|
||||
|
||||
it('cancels name editing on Escape', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
await wrapper.find('h2').trigger('click');
|
||||
const input = wrapper.find('input');
|
||||
await input.setValue('New Name');
|
||||
await input.trigger('keydown', { key: 'Escape' });
|
||||
expect(wrapper.emitted('update:name')).toBeUndefined();
|
||||
expect(wrapper.find('h2').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the recent sessions empty state', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.text()).toContain('Recent');
|
||||
expect(wrapper.text()).toContain('No conversations yet');
|
||||
});
|
||||
|
||||
it('renders the chat input', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.find('[data-testid="chat-input-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the icon picker', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.find('[data-testid="icon-picker-stub"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { CHATHUB_TO_CATALOG, CATALOG_TO_CHATHUB } from '../provider-mapping';
|
||||
|
||||
/**
|
||||
* Tests the shared provider mapping used by AgentSettingsSidebar
|
||||
* to translate between ChatHub provider IDs and Agent SDK catalog IDs.
|
||||
*/
|
||||
describe('Provider ID mapping', () => {
|
||||
it('maps all ChatHub providers to catalog IDs', () => {
|
||||
expect(CHATHUB_TO_CATALOG.openai).toBe('openai');
|
||||
expect(CHATHUB_TO_CATALOG.anthropic).toBe('anthropic');
|
||||
expect(CHATHUB_TO_CATALOG.xAiGrok).toBe('xai');
|
||||
expect(CHATHUB_TO_CATALOG.deepSeek).toBe('deepseek');
|
||||
expect(CHATHUB_TO_CATALOG.mistralCloud).toBe('mistral');
|
||||
expect(CHATHUB_TO_CATALOG.openRouter).toBe('openrouter');
|
||||
expect(CHATHUB_TO_CATALOG.awsBedrock).toBe('aws-bedrock');
|
||||
});
|
||||
|
||||
it('reverse maps catalog IDs back to ChatHub IDs', () => {
|
||||
expect(CATALOG_TO_CHATHUB.openai).toBe('openai');
|
||||
expect(CATALOG_TO_CHATHUB.anthropic).toBe('anthropic');
|
||||
expect(CATALOG_TO_CHATHUB.xai).toBe('xAiGrok');
|
||||
expect(CATALOG_TO_CHATHUB.deepseek).toBe('deepSeek');
|
||||
expect(CATALOG_TO_CHATHUB.mistral).toBe('mistralCloud');
|
||||
expect(CATALOG_TO_CHATHUB.openrouter).toBe('openRouter');
|
||||
expect(CATALOG_TO_CHATHUB['aws-bedrock']).toBe('awsBedrock');
|
||||
});
|
||||
|
||||
it('prefers azureOpenAi over azureEntraId for azure-openai reverse mapping', () => {
|
||||
expect(CATALOG_TO_CHATHUB['azure-openai']).toBe('azureOpenAi');
|
||||
});
|
||||
|
||||
it('has a reverse mapping for every unique catalog ID', () => {
|
||||
const uniqueCatalogIds = new Set(Object.values(CHATHUB_TO_CATALOG));
|
||||
for (const catalogId of uniqueCatalogIds) {
|
||||
expect(CATALOG_TO_CHATHUB[catalogId]).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Component tests ---
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: { projectId: 'test-project', agentId: 'test-agent' } }),
|
||||
RouterLink: { template: '<a><slot/></a>' },
|
||||
}));
|
||||
|
||||
const baseTextFn = (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'agents.settings.title': 'Settings',
|
||||
'agents.settings.cancel': 'Cancel',
|
||||
'agents.settings.save': 'Save',
|
||||
'agents.settings.unsavedChanges': 'Unsaved changes',
|
||||
'agents.settings.model': 'Model',
|
||||
'agents.settings.instructions': 'Instructions',
|
||||
'agents.settings.instructions.placeholder': 'Enter instructions...',
|
||||
'agents.settings.triggers': 'Triggers',
|
||||
'agents.settings.triggers.placeholder': 'No triggers configured',
|
||||
'agents.settings.tools': 'Tools',
|
||||
'agents.settings.advanced': 'Advanced',
|
||||
'agents.settings.code': 'Code',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
};
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({ baseText: baseTextFn }),
|
||||
i18n: { baseText: baseTextFn },
|
||||
}));
|
||||
|
||||
vi.mock('@/features/settings/users/users.store', () => ({
|
||||
useUsersStore: () => ({ currentUserId: 'user-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/chatHub/chat.store', () => ({
|
||||
useChatStore: () => ({
|
||||
agents: {},
|
||||
fetchAgents: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/chatHub/composables/useChatCredentials', () => ({
|
||||
useChatCredentials: () => ({
|
||||
credentialsByProvider: { value: null },
|
||||
selectCredential: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/chatHub/chat.utils', () => ({
|
||||
isLlmProviderModel: () => true,
|
||||
}));
|
||||
|
||||
const mockConfig = {
|
||||
name: 'Test Agent',
|
||||
model: 'anthropic/claude-sonnet-4-6',
|
||||
credential: 'cred-1',
|
||||
instructions: 'Be helpful',
|
||||
};
|
||||
|
||||
describe('AgentSettingsSidebar', () => {
|
||||
async function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const { default: AgentSettingsSidebar } = await import(
|
||||
'../components/AgentSettingsSidebar.vue'
|
||||
);
|
||||
return mount(AgentSettingsSidebar, {
|
||||
props: {
|
||||
config: mockConfig,
|
||||
agentTools: {},
|
||||
updatedAt: '2026-04-09T00:00:00Z',
|
||||
isDirty: false,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ModelSelector: { template: '<div data-testid="model-selector-stub" />' },
|
||||
AgentToolsPanel: { template: '<div data-testid="tools-panel-stub" />' },
|
||||
AgentMemoryPanel: { template: '<div data-testid="memory-panel-stub" />' },
|
||||
AgentCodeEditor: { template: '<div data-testid="code-editor-stub" />' },
|
||||
N8nButton: {
|
||||
template:
|
||||
'<button :disabled="disabled" @click="$emit(\'click\')"><slot/>{{ label }}</button>',
|
||||
props: ['type', 'size', 'label', 'disabled'],
|
||||
emits: ['click'],
|
||||
},
|
||||
N8nCallout: { template: '<div data-testid="callout-stub"><slot/></div>' },
|
||||
N8nIcon: { template: '<span />', props: ['icon', 'size'] },
|
||||
N8nInput: {
|
||||
template: '<textarea data-testid="instructions-input" />',
|
||||
props: ['modelValue', 'type', 'rows', 'placeholder'],
|
||||
},
|
||||
N8nText: {
|
||||
template: '<span v-bind="$attrs"><slot/></span>',
|
||||
props: ['tag', 'bold', 'size', 'color'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the settings header', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.text()).toContain('Settings');
|
||||
}, 15_000);
|
||||
|
||||
it('renders all sections (Model, Instructions, Triggers, Tools, Advanced, Code)', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.text()).toContain('Model');
|
||||
expect(wrapper.text()).toContain('Instructions');
|
||||
expect(wrapper.text()).toContain('Triggers');
|
||||
expect(wrapper.text()).toContain('Tools');
|
||||
expect(wrapper.text()).toContain('Advanced');
|
||||
expect(wrapper.text()).toContain('Code');
|
||||
});
|
||||
|
||||
it('shows Save and Cancel buttons', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.text()).toContain('Save');
|
||||
expect(wrapper.text()).toContain('Cancel');
|
||||
});
|
||||
|
||||
it('disables Save/Cancel when not dirty', async () => {
|
||||
const wrapper = await renderComponent({ isDirty: false });
|
||||
const buttons = wrapper.findAll('button');
|
||||
const cancelBtn = buttons.find((b) => b.text().includes('Cancel'));
|
||||
expect(cancelBtn?.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows unsaved changes banner when dirty', async () => {
|
||||
const wrapper = await renderComponent({ isDirty: true });
|
||||
expect(wrapper.text()).toContain('Unsaved changes');
|
||||
});
|
||||
|
||||
it('hides unsaved changes banner when not dirty', async () => {
|
||||
const wrapper = await renderComponent({ isDirty: false });
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes');
|
||||
});
|
||||
|
||||
it('emits save when Save button is clicked', async () => {
|
||||
const wrapper = await renderComponent({ isDirty: true });
|
||||
const buttons = wrapper.findAll('button');
|
||||
const saveBtn = buttons.find((b) => b.text().includes('Save'));
|
||||
await saveBtn?.trigger('click');
|
||||
expect(wrapper.emitted('save')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits cancel when Cancel button is clicked', async () => {
|
||||
const wrapper = await renderComponent({ isDirty: true });
|
||||
const buttons = wrapper.findAll('button');
|
||||
const cancelBtn = buttons.find((b) => b.text().includes('Cancel'));
|
||||
await cancelBtn?.trigger('click');
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the model selector', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.find('[data-testid="model-selector-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('expands Tools section when clicked', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.find('[data-testid="tools-panel-stub"]').exists()).toBe(false);
|
||||
|
||||
const toolsHeader = wrapper.findAll('button').find((b) => b.text().includes('Tools'));
|
||||
await toolsHeader?.trigger('click');
|
||||
expect(wrapper.find('[data-testid="tools-panel-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('expands Code section when clicked', async () => {
|
||||
const wrapper = await renderComponent();
|
||||
expect(wrapper.find('[data-testid="code-editor-stub"]').exists()).toBe(false);
|
||||
|
||||
const codeHeader = wrapper.findAll('button').find((b) => b.text().includes('Code'));
|
||||
await codeHeader?.trigger('click');
|
||||
expect(wrapper.find('[data-testid="code-editor-stub"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRoute: () => ({ params: { projectId: 'test-project' }, query: {} }),
|
||||
RouterLink: { template: '<a><slot/></a>' },
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/settings/users/users.store', () => ({
|
||||
useUsersStore: () => ({ currentUser: { firstName: 'Test' } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/collaboration/projects/projects.store', () => ({
|
||||
useProjectsStore: () => ({ personalProject: { id: 'test-project' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../composables/useAgentApi', () => ({
|
||||
createAgent: vi.fn().mockResolvedValue({ id: 'new-agent-id', name: 'New Agent' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string, opts?: { interpolate?: Record<string, string> }) => {
|
||||
if (key === 'agents.newAgent.heading') {
|
||||
return `Let's build something${opts?.interpolate?.name ?? ''}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('NewAgentView', () => {
|
||||
async function renderView() {
|
||||
const { default: NewAgentView } = await import('../views/NewAgentView.vue');
|
||||
return mount(NewAgentView, {
|
||||
global: {
|
||||
stubs: {
|
||||
ChatInputBase: { template: '<div data-testid="chat-input-stub" />' },
|
||||
N8nButton: { template: '<button v-bind="$attrs"><slot/></button>' },
|
||||
N8nText: { template: '<span v-bind="$attrs"><slot/></span>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the heading with user name', async () => {
|
||||
const wrapper = await renderView();
|
||||
expect(wrapper.text()).toContain("Let's build something, Test");
|
||||
}, 15_000);
|
||||
|
||||
it('renders 3 suggestion cards', async () => {
|
||||
const wrapper = await renderView();
|
||||
const cards = wrapper.findAll('[data-testid="agent-suggestion-card"]');
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders Create blank button', async () => {
|
||||
const wrapper = await renderView();
|
||||
const btn = wrapper.find('[data-testid="create-blank-agent"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('populates input when clicking a suggestion', async () => {
|
||||
const wrapper = await renderView();
|
||||
const cards = wrapper.findAll('[data-testid="agent-suggestion-card"]');
|
||||
await cards[0].trigger('click');
|
||||
// The component should have set inputText — verify via the component's internal state
|
||||
expect((wrapper.vm as unknown as { inputText: string }).inputText).toContain('SEO');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS, NEW_AGENT_VIEW } from '../constants';
|
||||
|
||||
describe('Agent constants', () => {
|
||||
it('exports all required route names', () => {
|
||||
expect(AGENTS_LIST_VIEW).toBe('AgentsListView');
|
||||
expect(AGENT_BUILDER_VIEW).toBe('AgentBuilderView');
|
||||
expect(PROJECT_AGENTS).toBe('ProjectAgents');
|
||||
expect(NEW_AGENT_VIEW).toBe('NewAgentView');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
|
||||
export interface AgentsEventBusEvents {
|
||||
/** Fired when an agent's name or metadata is updated */
|
||||
agentUpdated: never;
|
||||
}
|
||||
|
||||
export const agentsEventBus = createEventBus<AgentsEventBusEvents>();
|
||||
|
|
@ -1,20 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { N8nIcon, N8nMarkdown, N8nPromptInput } from '@n8n/design-system';
|
||||
import { ref, reactive, computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
||||
import { N8nIcon, N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { getBuilderMessages, clearBuilderMessages } from '../composables/useAgentApi';
|
||||
import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChunk.vue';
|
||||
import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible?: boolean;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
mode?: 'panel' | 'inline';
|
||||
initialMessage?: string;
|
||||
}>(),
|
||||
{
|
||||
visible: true,
|
||||
mode: 'panel',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
codeUpdated: [];
|
||||
codeDelta: [delta: string];
|
||||
configUpdated: [];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
interface ChatMessage {
|
||||
|
|
@ -23,19 +36,39 @@ interface ChatMessage {
|
|||
content: string;
|
||||
thinking?: string;
|
||||
toolCalls?: Array<{ tool: string; input?: unknown; output?: unknown }>;
|
||||
status?: 'streaming' | 'success' | 'error';
|
||||
}
|
||||
|
||||
const activeTab = ref<'test' | 'builder'>('test');
|
||||
const testMessages = ref<ChatMessage[]>([]);
|
||||
const activeTab = ref<'test' | 'builder'>(props.mode === 'inline' ? 'builder' : 'test');
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const builderMessages = ref<ChatMessage[]>([]);
|
||||
const inputText = ref('');
|
||||
const isStreaming = ref(false);
|
||||
const abortController = ref<AbortController | null>(null);
|
||||
const builderHistoryLoaded = ref(false);
|
||||
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
|
||||
const inputRef = useTemplateRef<InstanceType<typeof N8nInput>>('inputRef');
|
||||
|
||||
const messages = computed(() =>
|
||||
activeTab.value === 'test' ? testMessages.value : builderMessages.value,
|
||||
const currentMessages = computed(() =>
|
||||
activeTab.value === 'builder' ? builderMessages.value : messages.value,
|
||||
);
|
||||
|
||||
const messagingState = computed<'idle' | 'waitingFirstChunk' | 'receiving'>(() => {
|
||||
if (!isStreaming.value) return 'idle';
|
||||
const msgs = currentMessages.value;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
if (!lastMsg || lastMsg.role === 'user') return 'waitingFirstChunk';
|
||||
return 'receiving';
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
void nextTick(() => {
|
||||
if (scrollRef.value) {
|
||||
scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Convert persisted agent messages into the frontend ChatMessage format. */
|
||||
function convertDbMessages(dbMessages: unknown[]): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
|
|
@ -52,7 +85,6 @@ function convertDbMessages(dbMessages: unknown[]): ChatMessage[] {
|
|||
}>;
|
||||
};
|
||||
if (!msg.role || !Array.isArray(msg.content)) continue;
|
||||
// Only show user and assistant messages
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
|
||||
let text = '';
|
||||
|
|
@ -120,41 +152,65 @@ function onSubmit() {
|
|||
void sendMessage();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputText.value.trim();
|
||||
if (!text || isStreaming.value) return;
|
||||
|
||||
const targetMessages = activeTab.value === 'test' ? testMessages : builderMessages;
|
||||
const targetMessages = activeTab.value === 'builder' ? builderMessages : messages;
|
||||
targetMessages.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
status: 'success',
|
||||
});
|
||||
inputText.value = '';
|
||||
scrollToBottom();
|
||||
|
||||
if (activeTab.value === 'builder') {
|
||||
await streamFromEndpoint('build', text, builderMessages);
|
||||
} else {
|
||||
await streamFromEndpoint('chat', text, testMessages);
|
||||
}
|
||||
const endpoint = activeTab.value === 'builder' ? 'build' : 'chat';
|
||||
await streamFromEndpoint(endpoint, text);
|
||||
}
|
||||
|
||||
async function streamFromEndpoint(
|
||||
endpoint: 'build' | 'chat',
|
||||
message: string,
|
||||
targetMessages: typeof testMessages,
|
||||
) {
|
||||
function sendMessageFromOutside(message: string) {
|
||||
inputText.value = message;
|
||||
void sendMessage();
|
||||
}
|
||||
|
||||
function stopGenerating() {
|
||||
abortController.value?.abort();
|
||||
}
|
||||
|
||||
defineExpose({ sendMessageFromOutside });
|
||||
|
||||
onMounted(() => {
|
||||
if (props.initialMessage) {
|
||||
sendMessageFromOutside(props.initialMessage);
|
||||
}
|
||||
});
|
||||
|
||||
async function streamFromEndpoint(endpoint: 'build' | 'chat', message: string) {
|
||||
isStreaming.value = true;
|
||||
let builderMutated = false;
|
||||
const targetMessages = endpoint === 'build' ? builderMessages : messages;
|
||||
const assistantMsg = reactive<ChatMessage>({
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
thinking: '',
|
||||
toolCalls: [],
|
||||
status: 'streaming',
|
||||
});
|
||||
let msgAdded = false;
|
||||
|
||||
const controller = new AbortController();
|
||||
abortController.value = controller;
|
||||
|
||||
try {
|
||||
const { baseUrl } = rootStore.restApiContext;
|
||||
const browserId = localStorage.getItem('n8n-browserId') ?? '';
|
||||
|
|
@ -167,10 +223,12 @@ async function streamFromEndpoint(
|
|||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ message }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
assistantMsg.content = `Error: ${response.statusText || 'Failed to reach agent'}`;
|
||||
assistantMsg.status = 'error';
|
||||
targetMessages.value.push(assistantMsg);
|
||||
return;
|
||||
}
|
||||
|
|
@ -204,12 +262,14 @@ async function streamFromEndpoint(
|
|||
if (!msgAdded) {
|
||||
targetMessages.value.push(assistantMsg);
|
||||
msgAdded = true;
|
||||
scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof data.text === 'string') {
|
||||
ensureMsg();
|
||||
assistantMsg.content += data.text;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
if (typeof data.thinking === 'string') {
|
||||
|
|
@ -217,6 +277,14 @@ async function streamFromEndpoint(
|
|||
assistantMsg.thinking = (assistantMsg.thinking ?? '') + data.thinking;
|
||||
}
|
||||
|
||||
if (typeof data.codeDelta === 'string') {
|
||||
emit('codeDelta', data.codeDelta);
|
||||
}
|
||||
|
||||
if (typeof data.code === 'string') {
|
||||
emit('codeUpdated');
|
||||
}
|
||||
|
||||
if (data.configUpdated !== undefined || data.toolUpdated !== undefined) {
|
||||
builderMutated = true;
|
||||
emit('configUpdated');
|
||||
|
|
@ -248,16 +316,26 @@ async function streamFromEndpoint(
|
|||
if (typeof data.error === 'string') {
|
||||
ensureMsg();
|
||||
assistantMsg.content += `\n\nError: ${data.error}`;
|
||||
assistantMsg.status = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assistantMsg.status = 'success';
|
||||
} catch (e) {
|
||||
if (!msgAdded) {
|
||||
targetMessages.value.push(assistantMsg);
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
assistantMsg.status = 'success';
|
||||
} else {
|
||||
if (!msgAdded) {
|
||||
targetMessages.value.push(assistantMsg);
|
||||
}
|
||||
assistantMsg.content = `Error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
assistantMsg.status = 'error';
|
||||
}
|
||||
assistantMsg.content = `Error: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
} finally {
|
||||
abortController.value = null;
|
||||
isStreaming.value = false;
|
||||
scrollToBottom();
|
||||
// Emit a final refresh after the builder stream completes to ensure the
|
||||
// latest config is shown even if no individual tool events fired it.
|
||||
if (endpoint === 'build' && builderMutated) {
|
||||
|
|
@ -268,8 +346,9 @@ async function streamFromEndpoint(
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-if="visible" :class="$style.panel">
|
||||
<div :class="$style.header">
|
||||
<aside v-if="visible" :class="[mode === 'inline' ? $style.inlinePanel : $style.panel]">
|
||||
<!-- Tabs header (only shown in panel mode) -->
|
||||
<div v-if="mode === 'panel'" :class="$style.header">
|
||||
<div :class="$style.tabs">
|
||||
<button
|
||||
:class="[$style.tabBtn, activeTab === 'test' && $style.tabBtnActive]"
|
||||
|
|
@ -296,97 +375,157 @@ async function streamFromEndpoint(
|
|||
>
|
||||
<N8nIcon icon="trash-2" :size="14" />
|
||||
</button>
|
||||
<button :class="$style.closeBtn" data-testid="chat-close-button" @click="emit('close')">
|
||||
<N8nIcon icon="x" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.messages">
|
||||
<div v-if="messages.length === 0 && !isStreaming" :class="$style.emptyState">
|
||||
<!-- Messages -->
|
||||
<div ref="scrollRef" :class="$style.messages">
|
||||
<div v-if="currentMessages.length === 0 && !isStreaming" :class="$style.emptyState">
|
||||
<N8nIcon icon="message-square" :size="32" />
|
||||
<p :class="$style.emptyTitle">
|
||||
{{ activeTab === 'test' ? 'Test your agent' : 'Build with AI' }}
|
||||
</p>
|
||||
<p :class="$style.emptySubtitle">
|
||||
{{
|
||||
activeTab === 'test'
|
||||
? 'Send a message to test your agent'
|
||||
: 'Describe what you want and the builder will write the code'
|
||||
}}
|
||||
</p>
|
||||
<N8nText tag="p" bold>Test your agent</N8nText>
|
||||
<N8nText size="small" color="text-light"> Send a message to start a conversation </N8nText>
|
||||
</div>
|
||||
<div v-else :class="$style.messageList">
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
v-for="msg in currentMessages"
|
||||
:key="msg.id"
|
||||
:class="[
|
||||
$style.message,
|
||||
msg.role === 'user' ? $style.messageUser : $style.messageAssistant,
|
||||
]"
|
||||
:class="[$style.message, msg.role === 'user' ? $style.user : $style.assistant]"
|
||||
>
|
||||
<!-- Thinking block -->
|
||||
<details v-if="msg.thinking" :class="$style.thinkingBlock">
|
||||
<summary :class="$style.thinkingSummary">Thinking...</summary>
|
||||
<pre :class="$style.thinkingContent">{{ msg.thinking }}</pre>
|
||||
</details>
|
||||
|
||||
<!-- Tool calls -->
|
||||
<div v-if="msg.toolCalls?.length" :class="$style.toolCalls">
|
||||
<div v-for="(tc, i) in msg.toolCalls" :key="i" :class="$style.toolCall">
|
||||
<span :class="$style.toolName">{{ tc.tool }}</span>
|
||||
<span v-if="tc.output !== undefined" :class="$style.toolStatus">✓</span>
|
||||
<span v-else :class="$style.toolStatusPending">⋯</span>
|
||||
</div>
|
||||
<!-- Avatar -->
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon v-if="msg.role === 'user'" icon="user" width="20" height="20" />
|
||||
<N8nIcon v-else icon="robot" width="20" height="20" />
|
||||
</div>
|
||||
|
||||
<!-- Message content -->
|
||||
<N8nMarkdown
|
||||
v-if="msg.role === 'assistant' && msg.content"
|
||||
:content="msg.content"
|
||||
:class="$style.markdownContent"
|
||||
/>
|
||||
<div v-else-if="msg.role === 'user'" :class="$style.userContent">{{ msg.content }}</div>
|
||||
<!-- Content -->
|
||||
<div :class="$style.content">
|
||||
<!-- Thinking -->
|
||||
<details v-if="msg.thinking" :class="$style.thinkingBlock">
|
||||
<summary :class="$style.thinkingSummary">
|
||||
<N8nIcon icon="brain" :size="12" />
|
||||
Thinking...
|
||||
</summary>
|
||||
<div :class="$style.thinkingContent">{{ msg.thinking }}</div>
|
||||
</details>
|
||||
|
||||
<!-- Tool calls -->
|
||||
<div v-if="msg.toolCalls?.length" :class="$style.toolCalls">
|
||||
<div v-for="(tc, i) in msg.toolCalls" :key="i" :class="$style.toolCall">
|
||||
<N8nIcon icon="wrench" :size="12" />
|
||||
<span :class="$style.toolName">{{ tc.tool }}</span>
|
||||
<N8nIcon
|
||||
v-if="tc.output !== undefined"
|
||||
icon="check"
|
||||
:size="12"
|
||||
:class="$style.toolDone"
|
||||
/>
|
||||
<ChatTypingIndicator v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User message -->
|
||||
<div v-if="msg.role === 'user'" :class="[$style.chatMessage, $style.chatMessageUser]">
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div
|
||||
v-else-if="msg.content"
|
||||
:class="[$style.chatMessage, { [$style.chatMessageError]: msg.status === 'error' }]"
|
||||
>
|
||||
<div :class="$style.markdownContent">
|
||||
<ChatMarkdownChunk
|
||||
:source="{ type: 'text', content: msg.content }"
|
||||
@open-artifact="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator for assistant still streaming with no content -->
|
||||
<ChatTypingIndicator
|
||||
v-if="
|
||||
msg.role === 'assistant' &&
|
||||
msg.status === 'streaming' &&
|
||||
!msg.content &&
|
||||
!msg.toolCalls?.length
|
||||
"
|
||||
:class="$style.typingIndicator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="
|
||||
isStreaming && messages.length > 0 && messages[messages.length - 1]?.role === 'user'
|
||||
"
|
||||
:class="$style.loadingIndicator"
|
||||
>
|
||||
<span :class="$style.dot" />
|
||||
<span :class="[$style.dot, $style.dot2]" />
|
||||
<span :class="[$style.dot, $style.dot3]" />
|
||||
<!-- Waiting for first chunk indicator -->
|
||||
<div v-if="messagingState === 'waitingFirstChunk'" :class="$style.message">
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon icon="robot" width="20" height="20" />
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<ChatTypingIndicator :class="$style.typingIndicator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div :class="$style.inputArea">
|
||||
<N8nPromptInput
|
||||
v-model="inputText"
|
||||
:placeholder="activeTab === 'builder' ? 'Ask the builder...' : 'Type a message...'"
|
||||
:streaming="isStreaming"
|
||||
:disabled="isStreaming"
|
||||
refocus-after-send
|
||||
data-testid="chat-input"
|
||||
@submit="onSubmit"
|
||||
/>
|
||||
<form :class="$style.prompt" @submit.prevent="onSubmit">
|
||||
<N8nInput
|
||||
ref="inputRef"
|
||||
:model-value="inputText"
|
||||
type="textarea"
|
||||
placeholder="Type a message..."
|
||||
autocomplete="off"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
autofocus
|
||||
:disabled="isStreaming"
|
||||
data-testid="chat-input"
|
||||
@update:model-value="inputText = $event"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div :class="$style.promptFooter">
|
||||
<div />
|
||||
<div :class="$style.promptActions">
|
||||
<N8nIconButton
|
||||
v-if="messagingState !== 'receiving'"
|
||||
type="submit"
|
||||
:disabled="isStreaming || !inputText.trim()"
|
||||
:title="locale.baseText('chatHub.chat.prompt.button.send')"
|
||||
:loading="messagingState === 'waitingFirstChunk'"
|
||||
icon="arrow-up"
|
||||
icon-size="large"
|
||||
@click.stop
|
||||
/>
|
||||
<N8nIconButton
|
||||
v-else
|
||||
native-type="button"
|
||||
:title="locale.baseText('chatHub.chat.prompt.button.stopGenerating')"
|
||||
icon="square"
|
||||
icon-size="large"
|
||||
@click.stop="stopGenerating"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
<style lang="scss" module>
|
||||
.panel {
|
||||
width: 400px;
|
||||
min-width: 400px;
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border-left: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inlinePanel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -448,27 +587,14 @@ async function streamFromEndpoint(
|
|||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color--text--tint-2);
|
||||
padding: var(--spacing--4xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
/* Messages area — matches ChatHub layout */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing--sm);
|
||||
padding: var(--spacing--lg) var(--spacing--xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
|
@ -481,107 +607,71 @@ async function streamFromEndpoint(
|
|||
color: var(--color--text--tint-2);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--bold);
|
||||
/* Message layout — mirrors ChatMessage.vue styles */
|
||||
.message {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color--background);
|
||||
color: var(--color--text--tint-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.emptySubtitle {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-2);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border-radius: var(--radius--lg);
|
||||
font-size: var(--font-size--sm);
|
||||
font-family: var(--font-family);
|
||||
.chatMessage {
|
||||
overflow-wrap: break-word;
|
||||
font-size: var(--font-size--md);
|
||||
line-height: var(--line-height--xl);
|
||||
max-width: 90%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messageUser {
|
||||
align-self: flex-end;
|
||||
background-color: var(--color--primary);
|
||||
color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.messageAssistant {
|
||||
align-self: flex-start;
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
color: var(--color--text);
|
||||
.chatMessageUser {
|
||||
padding: var(--spacing--2xs) var(--spacing--sm);
|
||||
border-radius: var(--radius--xl);
|
||||
background-color: var(--color--background);
|
||||
white-space: pre-wrap;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.userContent {
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
font-family: var(--font-family);
|
||||
white-space: pre-wrap;
|
||||
.chatMessageError {
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--danger--tint-4);
|
||||
border: var(--border-width) var(--border-style) var(--color--danger--tint-3);
|
||||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.markdownContent {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text--shade-1);
|
||||
font-size: var(--font-size--md);
|
||||
line-height: var(--line-height--xl);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.markdownContent :global(p) {
|
||||
margin: 0 0 var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.markdownContent :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdownContent :global(pre) {
|
||||
background-color: var(--color--foreground);
|
||||
padding: var(--spacing--3xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-size: var(--font-size--2xs);
|
||||
margin: var(--spacing--3xs) 0;
|
||||
}
|
||||
|
||||
.markdownContent :global(code) {
|
||||
font-size: var(--font-size--2xs);
|
||||
background-color: var(--color--foreground);
|
||||
padding: 1px var(--spacing--5xs);
|
||||
border-radius: var(--radius--sm);
|
||||
}
|
||||
|
||||
.markdownContent :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdownContent :global(h1),
|
||||
.markdownContent :global(h2),
|
||||
.markdownContent :global(h3) {
|
||||
font-size: var(--font-size--xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
margin: var(--spacing--3xs) 0;
|
||||
}
|
||||
|
||||
.markdownContent :global(ul),
|
||||
.markdownContent :global(ol) {
|
||||
padding-left: var(--spacing--sm);
|
||||
margin: var(--spacing--3xs) 0;
|
||||
|
||||
> *:last-child > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
> *:first-child > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Thinking block */
|
||||
.thinkingBlock {
|
||||
margin-bottom: var(--spacing--4xs);
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
|
|
@ -589,6 +679,9 @@ async function streamFromEndpoint(
|
|||
cursor: pointer;
|
||||
color: var(--color--text--tint-2);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.thinkingContent {
|
||||
|
|
@ -601,74 +694,96 @@ async function streamFromEndpoint(
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Tool calls */
|
||||
.toolCalls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
margin-bottom: var(--spacing--4xs);
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--5xs);
|
||||
font-size: var(--font-size--3xs);
|
||||
background-color: var(--color--foreground);
|
||||
padding: var(--spacing--5xs) var(--spacing--3xs);
|
||||
border-radius: var(--radius--sm);
|
||||
gap: var(--spacing--4xs);
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border-radius: var(--radius);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
color: var(--color--text--tint-1);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.toolStatus {
|
||||
.toolDone {
|
||||
color: var(--color--success);
|
||||
}
|
||||
|
||||
.toolStatusPending {
|
||||
color: var(--color--text--tint-2);
|
||||
}
|
||||
|
||||
.loadingIndicator {
|
||||
display: flex;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--2xs) 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--spacing--3xs);
|
||||
height: var(--spacing--3xs);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color--text--tint-2);
|
||||
animation: dotPulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.dot2 {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.dot3 {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
.typingIndicator {
|
||||
margin-top: var(--spacing--xs);
|
||||
}
|
||||
|
||||
/* Input area — matches ChatPromptFull layout */
|
||||
.inputArea {
|
||||
padding: var(--spacing--2xs) var(--spacing--sm);
|
||||
padding: var(--spacing--sm);
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& textarea {
|
||||
font-size: var(--font-size--md);
|
||||
line-height: 1.5em;
|
||||
padding: var(--spacing--sm);
|
||||
padding-bottom: var(--spacing--3xl);
|
||||
color: var(--color--text--shade-1);
|
||||
box-shadow: 0 10px 24px 0 var(--color--black-alpha-100);
|
||||
border-radius: var(--radius--xl);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.n8n-input__wrapper) {
|
||||
--input--radius: var(--radius--xl);
|
||||
}
|
||||
}
|
||||
|
||||
.promptFooter {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
width: calc(100% - 2px);
|
||||
z-index: 10;
|
||||
background: var(--color--background--light-2);
|
||||
border-radius: var(--radius--xl);
|
||||
padding: var(--spacing--sm);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--sm);
|
||||
pointer-events: none;
|
||||
|
||||
& > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.promptActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
|
||||
& button path {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
.badge {
|
||||
font-size: var(--font-size--3xs);
|
||||
color: var(--color--text--tint-2);
|
||||
color: var(--color--text--tint-1);
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
padding: var(--spacing--5xs) var(--spacing--3xs);
|
||||
border-radius: var(--radius--sm);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { N8nIconPicker, N8nText } from '@n8n/design-system';
|
||||
import type { IconOrEmoji } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
agentName: string;
|
||||
agentDescription: string | null;
|
||||
agentIcon: IconOrEmoji;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'send-message': [message: string];
|
||||
'update:name': [name: string];
|
||||
'update:description': [description: string];
|
||||
'update:icon': [icon: IconOrEmoji];
|
||||
}>();
|
||||
|
||||
const inputText = ref('');
|
||||
const editingName = ref(false);
|
||||
const editingDescription = ref(false);
|
||||
const localName = ref(props.agentName);
|
||||
const localDescription = ref(props.agentDescription ?? '');
|
||||
|
||||
function saveName() {
|
||||
const trimmed = localName.value.trim();
|
||||
if (trimmed && trimmed !== props.agentName) {
|
||||
emit('update:name', trimmed);
|
||||
} else {
|
||||
localName.value = props.agentName;
|
||||
}
|
||||
editingName.value = false;
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
const trimmed = localDescription.value.trim();
|
||||
if (trimmed !== (props.agentDescription ?? '')) {
|
||||
emit('update:description', trimmed);
|
||||
}
|
||||
editingDescription.value = false;
|
||||
}
|
||||
|
||||
function onNameKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
saveName();
|
||||
} else if (event.key === 'Escape') {
|
||||
localName.value = props.agentName;
|
||||
editingName.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDescriptionKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
saveDescription();
|
||||
} else if (event.key === 'Escape') {
|
||||
localDescription.value = props.agentDescription ?? '';
|
||||
editingDescription.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitMessage() {
|
||||
if (!inputText.value.trim()) return;
|
||||
emit('send-message', inputText.value.trim());
|
||||
inputText.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.hero">
|
||||
<div :class="$style.agentIcon">
|
||||
<N8nIconPicker
|
||||
:model-value="agentIcon"
|
||||
button-size="xlarge"
|
||||
:button-tooltip="locale.baseText('agents.home.iconPicker.tooltip')"
|
||||
@update:model-value="emit('update:icon', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-if="editingName"
|
||||
v-model="localName"
|
||||
:class="$style.nameInput"
|
||||
autofocus
|
||||
@blur="saveName"
|
||||
@keydown="onNameKeydown"
|
||||
/>
|
||||
<h2
|
||||
v-else
|
||||
:class="$style.agentName"
|
||||
@click="
|
||||
localName = agentName;
|
||||
editingName = true;
|
||||
"
|
||||
>
|
||||
{{ agentName || locale.baseText('agents.home.untitledAgent') }}
|
||||
</h2>
|
||||
|
||||
<textarea
|
||||
v-if="editingDescription"
|
||||
v-model="localDescription"
|
||||
:class="$style.descriptionInput"
|
||||
:placeholder="locale.baseText('agents.home.addDescription')"
|
||||
rows="3"
|
||||
autofocus
|
||||
@blur="saveDescription"
|
||||
@keydown="onDescriptionKeydown"
|
||||
/>
|
||||
<N8nText
|
||||
v-else
|
||||
:class="$style.description"
|
||||
size="small"
|
||||
color="text-light"
|
||||
@click="
|
||||
localDescription = agentDescription ?? '';
|
||||
editingDescription = true;
|
||||
"
|
||||
>
|
||||
{{ agentDescription || locale.baseText('agents.home.addDescription') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.chatInput">
|
||||
<ChatInputBase
|
||||
v-model="inputText"
|
||||
:placeholder="locale.baseText('agents.home.sendMessage')"
|
||||
:is-streaming="false"
|
||||
:can-submit="inputText.trim().length > 0"
|
||||
:show-voice="true"
|
||||
:show-attach="true"
|
||||
@submit="submitMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.recent">
|
||||
<div :class="$style.recentHeader">
|
||||
<N8nText size="small" color="text-light" bold>{{
|
||||
locale.baseText('agents.home.recent')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.emptyState">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ locale.baseText('agents.home.emptyState') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing--2xl) var(--spacing--lg) var(--spacing--lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
margin-bottom: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.agentIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color--text--tint-1);
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
|
||||
> * > * :global(.button) {
|
||||
--button--color--background: transparent;
|
||||
--button--color--background-hover: var(--color--foreground--tint-2);
|
||||
--button--color--background-active: var(--color--foreground--tint-1);
|
||||
--button--shadow: none;
|
||||
--button--shadow--hover: none;
|
||||
--button--shadow--active: none;
|
||||
--button--border--shadow: none;
|
||||
--button--border--shadow--hover: none;
|
||||
--button--border--shadow--active: none;
|
||||
}
|
||||
}
|
||||
|
||||
.agentName {
|
||||
font-size: var(--font-size--xl);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.agentName:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
font-size: var(--font-size--xl);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
background: none;
|
||||
border: var(--border-width) var(--border-style) var(--color--primary);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
outline: none;
|
||||
text-align: center;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.description {
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.description:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.descriptionInput {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
background: none;
|
||||
border: var(--border-width) var(--border-style) var(--color--primary);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
outline: none;
|
||||
font-family: var(--font-family);
|
||||
resize: vertical;
|
||||
min-width: 300px;
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.chatInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.recent {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.recentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: var(--spacing--lg) 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,467 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { N8nButton, N8nCallout, N8nIcon, N8nInput, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { ChatHubConversationModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
|
||||
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import type { CustomToolEntry } from '../agent.types';
|
||||
import { CHATHUB_TO_CATALOG, CATALOG_TO_CHATHUB } from '../provider-mapping';
|
||||
import AgentToolsPanel from './AgentToolsPanel.vue';
|
||||
import AgentMemoryPanel from './AgentMemoryPanel.vue';
|
||||
import AgentCodeEditor from './AgentCodeEditor.vue';
|
||||
|
||||
const locale = useI18n();
|
||||
const usersStore = useUsersStore();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const props = defineProps<{
|
||||
config: AgentJsonConfig | null;
|
||||
agentTools: Record<string, CustomToolEntry>;
|
||||
updatedAt: string;
|
||||
isDirty: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:config': [changes: Partial<AgentJsonConfig>];
|
||||
save: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
// --- Model & credential state (reusing ChatHub infrastructure) ---
|
||||
const { credentialsByProvider, selectCredential } = useChatCredentials(
|
||||
usersStore.currentUserId ?? 'anonymous',
|
||||
);
|
||||
|
||||
const instructions = ref(props.config?.instructions ?? '');
|
||||
|
||||
// Fetch agents when credentials are ready
|
||||
watch(
|
||||
credentialsByProvider,
|
||||
(credentials) => {
|
||||
if (credentials) {
|
||||
void chatStore.fetchAgents(credentials);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** Parse "provider/model" string into provider and model parts */
|
||||
function parseModelString(model: string): { provider: string; name: string } | null {
|
||||
const slashIndex = model.indexOf('/');
|
||||
if (slashIndex <= 0) return null;
|
||||
return { provider: model.slice(0, slashIndex), name: model.slice(slashIndex + 1) };
|
||||
}
|
||||
|
||||
// Build a ChatModelDto from the current config so ModelSelector can show it as selected
|
||||
const selectedAgent = computed<ChatModelDto | null>(() => {
|
||||
if (!props.config?.model) return null;
|
||||
|
||||
const parsed = parseModelString(props.config.model);
|
||||
if (!parsed) return null;
|
||||
|
||||
const chatHubProvider = CATALOG_TO_CHATHUB[parsed.provider];
|
||||
if (!chatHubProvider) return null;
|
||||
|
||||
return {
|
||||
model: {
|
||||
provider: chatHubProvider,
|
||||
model: parsed.name,
|
||||
} as ChatHubConversationModel,
|
||||
name: parsed.name,
|
||||
description: null,
|
||||
icon: null,
|
||||
updatedAt: null,
|
||||
createdAt: null,
|
||||
metadata: {} as ChatModelDto['metadata'],
|
||||
groupName: null,
|
||||
groupIcon: null,
|
||||
};
|
||||
});
|
||||
|
||||
function onModelChange(selection: ChatHubConversationModel) {
|
||||
if (!isLlmProviderModel(selection)) return;
|
||||
|
||||
const catalogProvider = CHATHUB_TO_CATALOG[selection.provider] ?? selection.provider;
|
||||
const credentialId = credentialsByProvider.value?.[selection.provider] ?? '';
|
||||
|
||||
emit('update:config', {
|
||||
model: `${catalogProvider}/${selection.model}`,
|
||||
credential: credentialId,
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectCredential(provider: ChatHubProvider, credentialId: string | null) {
|
||||
selectCredential(provider, credentialId);
|
||||
|
||||
// If this is the currently selected provider, also update the config credential
|
||||
if (props.config?.model) {
|
||||
const parsed = parseModelString(props.config.model);
|
||||
const currentChatHubProvider = parsed ? CATALOG_TO_CHATHUB[parsed.provider] : undefined;
|
||||
if (currentChatHubProvider === provider) {
|
||||
emit('update:config', { credential: credentialId ?? '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.config,
|
||||
(config) => {
|
||||
if (!config) return;
|
||||
instructions.value = config.instructions ?? '';
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
function onInstructionsChange(value: string) {
|
||||
instructions.value = value;
|
||||
emit('update:config', { instructions: value });
|
||||
}
|
||||
|
||||
// --- Collapsible sections ---
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
triggers: false,
|
||||
tools: false,
|
||||
advanced: false,
|
||||
code: false,
|
||||
});
|
||||
|
||||
function toggleSection(section: string) {
|
||||
expandedSections.value[section] = !expandedSections.value[section];
|
||||
}
|
||||
|
||||
// --- Resizable width ---
|
||||
const sidebarWidth = ref(480);
|
||||
const MIN_WIDTH = 360;
|
||||
const MAX_WIDTH = 700;
|
||||
|
||||
function onResizeStart(event: MouseEvent) {
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth.value;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = startX - e.clientX;
|
||||
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth + delta));
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
:class="$style.sidebar"
|
||||
:style="{ width: `${sidebarWidth}px`, minWidth: `${MIN_WIDTH}px` }"
|
||||
>
|
||||
<!-- Resize handle -->
|
||||
<div :class="$style.resizeHandle" @mousedown="onResizeStart" />
|
||||
|
||||
<!-- Sidebar header -->
|
||||
<div :class="$style.header">
|
||||
<N8nText tag="span" bold>{{ locale.baseText('agents.settings.title') }}</N8nText>
|
||||
<div :class="$style.headerActions">
|
||||
<button :class="$style.cancelBtn" :disabled="!isDirty" @click="emit('cancel')">
|
||||
{{ locale.baseText('agents.settings.cancel') }}
|
||||
</button>
|
||||
<N8nButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:label="locale.baseText('agents.settings.save')"
|
||||
:disabled="!isDirty"
|
||||
@click="emit('save')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unsaved changes banner -->
|
||||
<N8nCallout v-if="isDirty" theme="warning" :class="$style.unsavedBanner">
|
||||
{{ locale.baseText('agents.settings.unsavedChanges') }}
|
||||
</N8nCallout>
|
||||
|
||||
<div :class="$style.body">
|
||||
<!-- Model section -->
|
||||
<div :class="[$style.staticSection, $style.modelSection]">
|
||||
<div :class="$style.sectionLabel">
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.model')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<ModelSelector
|
||||
:selected-agent="selectedAgent"
|
||||
:include-custom-agents="false"
|
||||
:credentials="credentialsByProvider"
|
||||
:agents="chatStore.agents"
|
||||
:is-loading="false"
|
||||
horizontal
|
||||
@change="onModelChange"
|
||||
@select-credential="onSelectCredential"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Instructions section -->
|
||||
<div :class="$style.staticSection">
|
||||
<div :class="$style.sectionLabel">
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.instructions')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<N8nInput
|
||||
:model-value="instructions"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:placeholder="locale.baseText('agents.settings.instructions.placeholder')"
|
||||
data-testid="agent-instructions-input"
|
||||
@update:model-value="onInstructionsChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Triggers (collapsible) -->
|
||||
<div :class="$style.section">
|
||||
<button :class="$style.sectionHeader" @click="toggleSection('triggers')">
|
||||
<div :class="$style.sectionHeaderLeft">
|
||||
<N8nIcon
|
||||
:icon="expandedSections.triggers ? 'chevron-down' : 'chevron-right'"
|
||||
:size="16"
|
||||
/>
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.triggers')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
|
||||
<N8nIcon icon="plus" :size="16" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="expandedSections.triggers" :class="$style.sectionContent">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ locale.baseText('agents.settings.triggers.placeholder') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools (collapsible) -->
|
||||
<div :class="$style.section">
|
||||
<button :class="$style.sectionHeader" @click="toggleSection('tools')">
|
||||
<div :class="$style.sectionHeaderLeft">
|
||||
<N8nIcon :icon="expandedSections.tools ? 'chevron-down' : 'chevron-right'" :size="16" />
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.tools')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
|
||||
<N8nIcon icon="plus" :size="16" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="expandedSections.tools" :class="$style.sectionContent">
|
||||
<AgentToolsPanel
|
||||
:config="config"
|
||||
:agent-tools="agentTools"
|
||||
@update:config="(changes) => emit('update:config', changes)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced (collapsible) -->
|
||||
<div :class="$style.section">
|
||||
<button :class="$style.sectionHeader" @click="toggleSection('advanced')">
|
||||
<div :class="$style.sectionHeaderLeft">
|
||||
<N8nIcon
|
||||
:icon="expandedSections.advanced ? 'chevron-down' : 'chevron-right'"
|
||||
:size="16"
|
||||
/>
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.advanced')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<span role="button" tabindex="0" :class="$style.addBtn" @click.stop @keydown.enter.stop>
|
||||
<N8nIcon icon="plus" :size="16" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="expandedSections.advanced" :class="$style.sectionContent">
|
||||
<AgentMemoryPanel
|
||||
:config="config"
|
||||
@update:config="(changes) => emit('update:config', changes)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config JSON (collapsed by default) -->
|
||||
<div :class="$style.section">
|
||||
<button :class="$style.sectionHeader" @click="toggleSection('code')">
|
||||
<div :class="$style.sectionHeaderLeft">
|
||||
<N8nIcon :icon="expandedSections.code ? 'chevron-down' : 'chevron-right'" :size="16" />
|
||||
<N8nText tag="span" bold size="small">{{
|
||||
locale.baseText('agents.settings.code')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="expandedSections.code" :class="$style.codeSection">
|
||||
<AgentCodeEditor
|
||||
:config="config"
|
||||
:agent-tools="agentTools"
|
||||
@update:config="(newConfig) => emit('update:config', newConfig)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.sidebar {
|
||||
position: relative;
|
||||
border-left: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.resizeHandle:hover {
|
||||
background-color: var(--color--primary--tint-2);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing--sm);
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size--sm);
|
||||
font-family: var(--font-family);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
padding: var(--spacing--4xs) var(--spacing--xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.cancelBtn:disabled {
|
||||
color: var(--color--text--tint-2);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cancelBtn:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.unsavedBanner {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.staticSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--sm);
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.modelSection {
|
||||
padding: var(--spacing--sm);
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sectionHeader:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.sectionHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color--text--tint-1);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.addBtn:hover {
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
padding: 0 var(--spacing--sm) var(--spacing--sm);
|
||||
}
|
||||
|
||||
.codeSection {
|
||||
height: 400px;
|
||||
min-height: 300px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -163,11 +163,12 @@ function inputSchemaProperties(
|
|||
|
||||
.heading {
|
||||
margin: 0;
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: var(--font-weight--regular);
|
||||
color: var(--color--text--tint-2);
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import type { AgentSchema, AgentResource, AgentJsonConfig } from '../types';
|
||||
import type { AgentResource, AgentJsonConfig } from '../types';
|
||||
|
||||
export const listAgents = async (
|
||||
context: IRestApiContext,
|
||||
|
|
@ -111,33 +111,6 @@ export const listAllAgents = async (
|
|||
);
|
||||
};
|
||||
|
||||
export const getAgentSchema = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<AgentSchema> => {
|
||||
return await makeRestApiRequest<AgentSchema>(
|
||||
context,
|
||||
'GET',
|
||||
`/projects/${projectId}/agents/v2/${agentId}/schema`,
|
||||
);
|
||||
};
|
||||
|
||||
export const patchAgentSchema = async (
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
schema: AgentSchema,
|
||||
updatedAt: string,
|
||||
): Promise<{ code: string; schema: AgentSchema; updatedAt: string }> => {
|
||||
return await makeRestApiRequest<{ code: string; schema: AgentSchema; updatedAt: string }>(
|
||||
context,
|
||||
'PATCH',
|
||||
`/projects/${projectId}/agents/v2/${agentId}/schema`,
|
||||
{ schema, updatedAt },
|
||||
);
|
||||
};
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import { ref } from 'vue';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { getAgentSchema, patchAgentSchema } from './useAgentApi';
|
||||
import type { AgentSchema } from '../types';
|
||||
|
||||
export function useAgentSchema() {
|
||||
const rootStore = useRootStore();
|
||||
const schema = ref<AgentSchema | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchSchema(projectId: string, agentId: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
schema.value = await getAgentSchema(rootStore.restApiContext, projectId, agentId);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSchema(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
fullSchema: AgentSchema,
|
||||
updatedAt: string,
|
||||
): Promise<{ updatedAt: string } | null> {
|
||||
try {
|
||||
const result = await patchAgentSchema(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
agentId,
|
||||
fullSchema,
|
||||
updatedAt,
|
||||
);
|
||||
schema.value = result.schema;
|
||||
return { updatedAt: result.updatedAt };
|
||||
} catch (error: unknown) {
|
||||
if ((error as { httpStatusCode?: number }).httpStatusCode === 409) {
|
||||
await fetchSchema(projectId, agentId);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { schema, loading, fetchSchema, updateSchema };
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export const AGENTS_LIST_VIEW = 'AgentsListView';
|
||||
export const AGENT_BUILDER_VIEW = 'AgentBuilderView';
|
||||
export const PROJECT_AGENTS = 'ProjectAgents';
|
||||
export const NEW_AGENT_VIEW = 'NewAgentView';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { type FrontendModuleDescription } from '@/app/moduleInitializer/module.types';
|
||||
import { AGENTS_LIST_VIEW, AGENT_BUILDER_VIEW, PROJECT_AGENTS } from '@/features/agents/constants';
|
||||
import {
|
||||
AGENTS_LIST_VIEW,
|
||||
AGENT_BUILDER_VIEW,
|
||||
NEW_AGENT_VIEW,
|
||||
PROJECT_AGENTS,
|
||||
} from '@/features/agents/constants';
|
||||
|
||||
const AgentsListView = async () => await import('@/features/agents/views/AgentsListView.vue');
|
||||
const AgentBuilderView = async () => await import('@/features/agents/views/AgentBuilderView.vue');
|
||||
const NewAgentView = async () => await import('@/features/agents/views/NewAgentView.vue');
|
||||
|
||||
export const AgentsModule: FrontendModuleDescription = {
|
||||
id: 'agents',
|
||||
|
|
@ -37,6 +43,14 @@ export const AgentsModule: FrontendModuleDescription = {
|
|||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: NEW_AGENT_VIEW,
|
||||
path: '/new-agent',
|
||||
component: NewAgentView,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
},
|
||||
},
|
||||
],
|
||||
projectTabs: {
|
||||
overview: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import type { ChatHubLLMProvider } from '@n8n/api-types';
|
||||
|
||||
/**
|
||||
* Maps ChatHub provider IDs (camelCase, e.g. 'xAiGrok') to Agent SDK catalog
|
||||
* IDs (lowercase, e.g. 'xai') used by the models.dev catalog and the agent
|
||||
* code's `.model('provider', 'model-name')` call.
|
||||
*/
|
||||
export const CHATHUB_TO_CATALOG: Record<string, string> = {
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
google: 'google',
|
||||
ollama: 'ollama',
|
||||
azureOpenAi: 'azure-openai',
|
||||
azureEntraId: 'azure-openai',
|
||||
awsBedrock: 'aws-bedrock',
|
||||
vercelAiGateway: 'vercel',
|
||||
xAiGrok: 'xai',
|
||||
groq: 'groq',
|
||||
openRouter: 'openrouter',
|
||||
deepSeek: 'deepseek',
|
||||
cohere: 'cohere',
|
||||
mistralCloud: 'mistral',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverse mapping: catalog ID → ChatHub provider ID.
|
||||
* When multiple ChatHub IDs map to the same catalog ID (e.g. azureOpenAi and
|
||||
* azureEntraId both map to 'azure-openai'), the first one wins.
|
||||
*/
|
||||
export const CATALOG_TO_CHATHUB: Record<string, ChatHubLLMProvider> = {};
|
||||
for (const [chatHub, catalog] of Object.entries(CHATHUB_TO_CATALOG)) {
|
||||
if (!(catalog in CATALOG_TO_CHATHUB)) {
|
||||
CATALOG_TO_CHATHUB[catalog] = chatHub as ChatHubLLMProvider;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,204 +1,237 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nButton } from '@n8n/design-system';
|
||||
import { N8nActionDropdown, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import type { IconOrEmoji } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { getAgent, updateAgent } from '../composables/useAgentApi';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { getAgent, updateAgent, deleteAgent } from '../composables/useAgentApi';
|
||||
import type { AgentResource, AgentJsonConfig } from '../types';
|
||||
import { AGENTS_LIST_VIEW } from '../constants';
|
||||
import { useAgentConfig } from '../composables/useAgentConfig';
|
||||
import AgentSidebar from '../components/AgentSidebar.vue';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { agentsEventBus } from '../agents.eventBus';
|
||||
import AgentChatPanel from '../components/AgentChatPanel.vue';
|
||||
import AgentCodeEditor from '../components/AgentCodeEditor.vue';
|
||||
import AgentIntegrationsPanel from '../components/AgentIntegrationsPanel.vue';
|
||||
import AgentOverviewPanel from '../components/AgentOverviewPanel.vue';
|
||||
import AgentToolsPanel from '../components/AgentToolsPanel.vue';
|
||||
import AgentPromptsPanel from '../components/AgentPromptsPanel.vue';
|
||||
import AgentMemoryPanel from '../components/AgentMemoryPanel.vue';
|
||||
import AgentHomeContent from '../components/AgentHomeContent.vue';
|
||||
import AgentSettingsSidebar from '../components/AgentSettingsSidebar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
const message = useMessage();
|
||||
|
||||
const projectId = computed(
|
||||
() => (route.params.projectId as string) ?? projectsStore.personalProject?.id ?? '',
|
||||
);
|
||||
const agentId = route.params.agentId as string;
|
||||
const agentId = computed(() => route.params.agentId as string);
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'overview');
|
||||
// UI state
|
||||
const chatActive = ref(false);
|
||||
const settingsVisible = ref(true);
|
||||
const agentName = ref('');
|
||||
const agentDescription = ref<string | null>(null);
|
||||
const agentIcon = ref<IconOrEmoji>({ type: 'icon', value: 'robot' });
|
||||
const agent = ref<AgentResource | null>(null);
|
||||
const updatedAt = ref<string>('');
|
||||
|
||||
const initialPrompt = ref<string | undefined>(undefined);
|
||||
|
||||
// Config
|
||||
const { config, fetchConfig, updateConfig } = useAgentConfig();
|
||||
const localConfig = ref<AgentJsonConfig | null>(null);
|
||||
|
||||
const originalConfigJson = ref('');
|
||||
const isDirty = ref(false);
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
void router.replace({ query: { ...route.query, tab } });
|
||||
});
|
||||
watch(
|
||||
() => route.query.tab as string | undefined,
|
||||
(tab) => {
|
||||
if (tab && tab !== activeTab.value) activeTab.value = tab;
|
||||
config,
|
||||
(c) => {
|
||||
if (c) {
|
||||
localConfig.value = deepCopy(c);
|
||||
originalConfigJson.value = JSON.stringify(c);
|
||||
isDirty.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const chatVisible = ref(true);
|
||||
const agentName = ref('');
|
||||
const agent = ref<AgentResource | null>(null);
|
||||
const editingName = ref(false);
|
||||
|
||||
const isDirty = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const { config, fetchConfig, updateConfig } = useAgentConfig();
|
||||
|
||||
async function fetchAgent() {
|
||||
const data = await getAgent(rootStore.restApiContext, projectId.value, agentId);
|
||||
const data = await getAgent(rootStore.restApiContext, projectId.value, agentId.value);
|
||||
agent.value = data;
|
||||
updatedAt.value = data.updatedAt;
|
||||
agentName.value = data.name;
|
||||
agentDescription.value = data.description ?? null;
|
||||
}
|
||||
|
||||
function onConfigUpdate(partial: Partial<AgentJsonConfig>) {
|
||||
// Optimistically merge so the UI stays responsive
|
||||
if (config.value) {
|
||||
Object.assign(config.value, partial);
|
||||
if (partial.config !== undefined) {
|
||||
config.value.config = { ...config.value.config, ...partial.config };
|
||||
}
|
||||
}
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
function onConfigJsonChange(newConfig: AgentJsonConfig) {
|
||||
config.value = newConfig;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!config.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateConfig(projectId.value, agentId, config.value);
|
||||
isDirty.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
if (!agentName.value.trim()) return;
|
||||
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId, {
|
||||
name: agentName.value.trim(),
|
||||
async function updateName(name: string) {
|
||||
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId.value, {
|
||||
name,
|
||||
});
|
||||
if (updated) {
|
||||
agent.value = updated;
|
||||
agentName.value = updated.name;
|
||||
updatedAt.value = updated.updatedAt;
|
||||
agentsEventBus.emit('agentUpdated');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDescription(description: string) {
|
||||
const updated = await updateAgent(rootStore.restApiContext, projectId.value, agentId.value, {
|
||||
description,
|
||||
} as Record<string, unknown>);
|
||||
if (updated) {
|
||||
agent.value = updated;
|
||||
agentDescription.value = updated.description ?? null;
|
||||
updatedAt.value = updated.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function startChat(msg: string) {
|
||||
initialPrompt.value = msg;
|
||||
chatActive.value = true;
|
||||
telemetry.track('User started agent chat', { agent_id: agentId.value });
|
||||
}
|
||||
|
||||
function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
|
||||
if (!localConfig.value) return;
|
||||
Object.assign(localConfig.value, updates);
|
||||
isDirty.value = JSON.stringify(localConfig.value) !== originalConfigJson.value;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!localConfig.value) return;
|
||||
await updateConfig(projectId.value, agentId.value, localConfig.value);
|
||||
originalConfigJson.value = JSON.stringify(localConfig.value);
|
||||
isDirty.value = false;
|
||||
telemetry.track('User saved agent settings', { agent_id: agentId.value });
|
||||
}
|
||||
|
||||
function cancelConfig() {
|
||||
if (config.value) {
|
||||
localConfig.value = deepCopy(config.value);
|
||||
isDirty.value = false;
|
||||
telemetry.track('User cancelled agent settings', { agent_id: agentId.value });
|
||||
}
|
||||
editingName.value = false;
|
||||
}
|
||||
|
||||
async function onConfigUpdated() {
|
||||
await Promise.all([fetchAgent(), fetchConfig(projectId.value, agentId)]);
|
||||
await Promise.all([fetchAgent(), fetchConfig(projectId.value, agentId.value)]);
|
||||
isDirty.value = false;
|
||||
}
|
||||
|
||||
function onNameKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void saveName();
|
||||
} else if (event.key === 'Escape') {
|
||||
agentName.value = agent.value?.name ?? '';
|
||||
editingName.value = false;
|
||||
const headerActions = [{ id: 'delete', label: 'Delete agent' }];
|
||||
|
||||
async function onHeaderAction(action: string) {
|
||||
if (action === 'delete') {
|
||||
const confirmed = await message.confirm(
|
||||
`Are you sure you want to delete "${agentName.value}"?`,
|
||||
'Delete agent',
|
||||
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
|
||||
);
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
await deleteAgent(rootStore.restApiContext, projectId.value, agentId.value);
|
||||
void router.push({ name: AGENTS_LIST_VIEW, params: { projectId: projectId.value } });
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
if (isDirty.value) void saveConfig();
|
||||
}
|
||||
}
|
||||
async function initialize() {
|
||||
agent.value = null;
|
||||
chatActive.value = false;
|
||||
agentIcon.value = { type: 'icon', value: 'robot' };
|
||||
initialPrompt.value = undefined;
|
||||
localConfig.value = null;
|
||||
originalConfigJson.value = '';
|
||||
isDirty.value = false;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAgent();
|
||||
await fetchConfig(projectId.value, agentId);
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
await fetchConfig(projectId.value, agentId.value);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
const prompt = route.query.prompt as string | undefined;
|
||||
if (prompt) {
|
||||
void router.replace({ query: { ...route.query, prompt: undefined } });
|
||||
startChat(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
watch(agentId, initialize, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.builder">
|
||||
<AgentSidebar :active-tab="activeTab" @select="activeTab = $event" />
|
||||
<div :class="$style.main">
|
||||
<div :class="$style.nameBar">
|
||||
<input
|
||||
v-if="editingName"
|
||||
v-model="agentName"
|
||||
:class="$style.nameInput"
|
||||
autofocus
|
||||
@blur="saveName"
|
||||
@keydown="onNameKeydown"
|
||||
/>
|
||||
<h2 v-else :class="$style.nameDisplay" @click="editingName = true">
|
||||
{{ agentName || 'Untitled Agent' }}
|
||||
</h2>
|
||||
|
||||
<div :class="$style.nameBarActions">
|
||||
<span v-if="isDirty && !saving" :class="$style.unsavedDot" title="Unsaved changes" />
|
||||
<N8nButton
|
||||
v-if="isDirty || saving"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
data-testid="save-config-btn"
|
||||
@click="saveConfig"
|
||||
<!-- Left column: center content with its own header -->
|
||||
<div :class="$style.mainColumn">
|
||||
<div :class="$style.mainHeader">
|
||||
<div :class="$style.mainHeaderLeft">
|
||||
<N8nIcon icon="robot" :size="16" />
|
||||
<N8nText tag="span" bold>{{
|
||||
agentName || locale.baseText('agents.home.untitledAgent')
|
||||
}}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.mainHeaderRight">
|
||||
<button
|
||||
v-if="chatActive"
|
||||
:class="$style.toggleBtn"
|
||||
data-testid="new-chat"
|
||||
@click="chatActive = false"
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</N8nButton>
|
||||
<N8nIcon icon="message-circle-plus" :size="16" />
|
||||
</button>
|
||||
<button
|
||||
:class="[$style.toggleBtn, settingsVisible && $style.toggleBtnActive]"
|
||||
data-testid="toggle-settings"
|
||||
@click="settingsVisible = !settingsVisible"
|
||||
>
|
||||
<N8nIcon icon="panel-right" :size="16" />
|
||||
</button>
|
||||
<N8nActionDropdown
|
||||
:items="headerActions"
|
||||
activator-icon="ellipsis-vertical"
|
||||
data-testid="agent-header-actions"
|
||||
@select="onHeaderAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentOverviewPanel
|
||||
v-if="activeTab === 'overview'"
|
||||
:config="config"
|
||||
@update:config="onConfigUpdate"
|
||||
/>
|
||||
<AgentPromptsPanel
|
||||
v-else-if="activeTab === 'prompts'"
|
||||
:config="config"
|
||||
@update:config="onConfigUpdate"
|
||||
/>
|
||||
<AgentToolsPanel
|
||||
v-else-if="activeTab === 'tools'"
|
||||
:config="config"
|
||||
:agent-tools="agent?.tools ?? {}"
|
||||
@update:config="onConfigUpdate"
|
||||
/>
|
||||
<AgentMemoryPanel
|
||||
v-else-if="activeTab === 'memory'"
|
||||
:config="config"
|
||||
@update:config="onConfigUpdate"
|
||||
/>
|
||||
<div v-else-if="activeTab === 'config'" :class="$style.configPanel">
|
||||
<AgentCodeEditor
|
||||
:config="config"
|
||||
:agent-tools="agent?.tools ?? {}"
|
||||
@update:config="onConfigJsonChange"
|
||||
<div :class="$style.mainBody">
|
||||
<AgentHomeContent
|
||||
v-if="!chatActive"
|
||||
:agent-name="agentName"
|
||||
:agent-description="agentDescription"
|
||||
:agent-icon="agentIcon"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
@send-message="startChat"
|
||||
@update:name="updateName"
|
||||
@update:description="updateDescription"
|
||||
@update:icon="agentIcon = $event"
|
||||
/>
|
||||
<AgentChatPanel
|
||||
v-else
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
mode="inline"
|
||||
:initial-message="initialPrompt"
|
||||
@config-updated="onConfigUpdated"
|
||||
/>
|
||||
</div>
|
||||
<AgentIntegrationsPanel
|
||||
v-else-if="activeTab === 'integrations'"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
/>
|
||||
</div>
|
||||
<AgentChatPanel
|
||||
:visible="chatVisible"
|
||||
:project-id="projectId"
|
||||
:agent-id="agentId"
|
||||
@close="chatVisible = false"
|
||||
@config-updated="onConfigUpdated"
|
||||
|
||||
<!-- Right column: settings sidebar with its own header -->
|
||||
<AgentSettingsSidebar
|
||||
v-if="settingsVisible"
|
||||
:config="localConfig"
|
||||
:agent-tools="agent?.tools ?? {}"
|
||||
:updated-at="updatedAt"
|
||||
:is-dirty="isDirty"
|
||||
@update:config="onConfigFieldUpdate"
|
||||
@save="saveConfig"
|
||||
@cancel="cancelConfig"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -211,68 +244,65 @@ onBeforeUnmount(() => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
.mainColumn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameBar {
|
||||
.mainHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--2xs) var(--spacing--sm);
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing--sm);
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.nameBarActions {
|
||||
.mainHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color--warning);
|
||||
}
|
||||
|
||||
.nameDisplay {
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--bold);
|
||||
gap: var(--spacing--2xs);
|
||||
color: var(--color--text);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.nameDisplay:hover {
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
.mainHeaderRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border: var(--border-width) var(--border-style) var(--color--primary);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
outline: none;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.configPanel {
|
||||
.mainBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color--text--tint-1);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
color: var(--color--text);
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ import { useProjectPages } from '@/features/collaboration/projects/composables/u
|
|||
import { listAgents, deleteAgent } from '../composables/useAgentApi';
|
||||
import type { AgentResource } from '../types';
|
||||
import { AGENT_BUILDER_VIEW } from '../constants';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const rootStore = useRootStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const message = useMessage();
|
||||
const insightsStore = useInsightsStore();
|
||||
const projectPages = useProjectPages();
|
||||
|
||||
|
|
@ -56,6 +59,12 @@ function onSelectAgent(agentId: string) {
|
|||
|
||||
async function onCardAction(action: string, agent: AgentResource) {
|
||||
if (action === 'delete') {
|
||||
const confirmed = await message.confirm(
|
||||
`Are you sure you want to delete "${agent.name}"?`,
|
||||
'Delete agent',
|
||||
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
|
||||
);
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
await deleteAgent(rootStore.restApiContext, projectId.value, agent.id);
|
||||
allAgents.value = allAgents.value.filter((a) => a.id !== agent.id);
|
||||
}
|
||||
|
|
@ -128,6 +137,7 @@ onMounted(fetchAgents);
|
|||
<div :class="$style.cardActions" @click.stop>
|
||||
<N8nActionDropdown
|
||||
:items="cardActions"
|
||||
activator-icon="ellipsis-vertical"
|
||||
data-testid="agent-card-actions"
|
||||
@select="onCardAction($event, data)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { N8nButton, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { createAgent } from '../composables/useAgentApi';
|
||||
import { AGENT_BUILDER_VIEW } from '../constants';
|
||||
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const usersStore = useUsersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const projectId = computed(() => projectsStore.personalProject?.id ?? '');
|
||||
const firstName = computed(() => usersStore.currentUser?.firstName ?? '');
|
||||
|
||||
const inputText = ref('');
|
||||
const isCreating = ref(false);
|
||||
|
||||
interface SuggestionTemplate {
|
||||
icon: string;
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const suggestions: SuggestionTemplate[] = [
|
||||
{
|
||||
icon: '🔍',
|
||||
name: 'SEO Audit',
|
||||
description:
|
||||
'An SEO auditor. Give it a website URL and it crawls the pages, identifies issues, and suggests improvements.',
|
||||
prompt:
|
||||
'Create an SEO auditor agent. It should accept a website URL, crawl the pages, identify SEO issues like missing meta tags, broken links, slow load times, and suggest improvements.',
|
||||
},
|
||||
{
|
||||
icon: '👋',
|
||||
name: 'Recruiting Sourcer',
|
||||
description:
|
||||
'A recruiting sourcer. Give it a job description and it finds matching candidates from multiple platforms.',
|
||||
prompt:
|
||||
'Create a recruiting sourcer agent. It should accept a job description, search for matching candidates across platforms, and compile a shortlist with contact info and relevance scores.',
|
||||
},
|
||||
{
|
||||
icon: '📬',
|
||||
name: 'Inbox Sorter',
|
||||
description:
|
||||
'Sort your inbox, classifying emails by sender and marking them as read when they match your rules.',
|
||||
prompt:
|
||||
'Create an inbox sorter agent. It should classify incoming emails by sender and topic, apply user-defined rules to mark as read, label, or archive, and provide a daily summary.',
|
||||
},
|
||||
];
|
||||
|
||||
async function createBlank() {
|
||||
if (isCreating.value) return;
|
||||
isCreating.value = true;
|
||||
try {
|
||||
const agent = await createAgent(rootStore.restApiContext, projectId.value, 'New Agent');
|
||||
telemetry.track('User created agent', {
|
||||
agent_id: agent.id,
|
||||
source: 'create_blank',
|
||||
});
|
||||
void router.push({
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId: projectId.value, agentId: agent.id },
|
||||
});
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDescription() {
|
||||
if (isCreating.value || !inputText.value.trim()) return;
|
||||
isCreating.value = true;
|
||||
try {
|
||||
const agent = await createAgent(rootStore.restApiContext, projectId.value, 'New Agent');
|
||||
telemetry.track('User created agent', {
|
||||
agent_id: agent.id,
|
||||
source: 'description_prompt',
|
||||
});
|
||||
void router.push({
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId: projectId.value, agentId: agent.id },
|
||||
query: { prompt: inputText.value.trim() },
|
||||
});
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectSuggestion(suggestion: SuggestionTemplate) {
|
||||
inputText.value = suggestion.prompt;
|
||||
telemetry.track('User selected agent suggestion', {
|
||||
suggestion_name: suggestion.name,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.page">
|
||||
<div :class="$style.topBar">
|
||||
<N8nText tag="span" bold size="large">{{ locale.baseText('agents.newAgent.title') }}</N8nText>
|
||||
<N8nButton
|
||||
:label="locale.baseText('agents.newAgent.createBlank')"
|
||||
type="secondary"
|
||||
size="medium"
|
||||
icon="file"
|
||||
:loading="isCreating"
|
||||
data-testid="create-blank-agent"
|
||||
@click="createBlank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.center">
|
||||
<h1 :class="$style.heading">
|
||||
{{
|
||||
locale.baseText('agents.newAgent.heading', {
|
||||
interpolate: { name: firstName ? `, ${firstName}` : '' },
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
|
||||
<div :class="$style.inputWrapper">
|
||||
<ChatInputBase
|
||||
v-model="inputText"
|
||||
:placeholder="locale.baseText('agents.newAgent.placeholder')"
|
||||
:is-streaming="false"
|
||||
:can-submit="inputText.trim().length > 0 && !isCreating"
|
||||
:show-voice="true"
|
||||
:show-attach="false"
|
||||
@submit="submitDescription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.suggestions">
|
||||
<N8nText :class="$style.suggestionsLabel" size="small" color="text-light">
|
||||
{{ locale.baseText('agents.newAgent.suggestions') }}
|
||||
</N8nText>
|
||||
|
||||
<div
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.name"
|
||||
:class="$style.suggestionCard"
|
||||
data-testid="agent-suggestion-card"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
>
|
||||
<span :class="$style.suggestionIcon">{{ suggestion.icon }}</span>
|
||||
<div :class="$style.suggestionContent">
|
||||
<N8nText tag="span" bold size="small">{{ suggestion.name }}</N8nText>
|
||||
<N8nText tag="span" size="small" color="text-light">
|
||||
{{ suggestion.description }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing--sm) var(--spacing--lg);
|
||||
border-bottom: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing--2xl) var(--spacing--lg);
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--font-size--2xl);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text);
|
||||
margin: 0 0 var(--spacing--lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
width: 100%;
|
||||
margin-bottom: var(--spacing--xl);
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestionsLabel {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.suggestionCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
border-radius: var(--radius--lg);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.suggestionCard:hover {
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
}
|
||||
|
||||
.suggestionIcon {
|
||||
font-size: var(--font-size--md);
|
||||
line-height: var(--line-height--xl);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestionContent {
|
||||
display: flex;
|
||||
gap: var(--spacing--4xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggestionContent span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -40,6 +40,7 @@ const {
|
|||
includeCustomAgents = true,
|
||||
credentials,
|
||||
text,
|
||||
horizontal = false,
|
||||
warnMissingCredentials = false,
|
||||
agents,
|
||||
isLoading,
|
||||
|
|
@ -48,6 +49,8 @@ const {
|
|||
includeCustomAgents?: boolean;
|
||||
credentials: CredentialsMap | null;
|
||||
text?: boolean;
|
||||
/** Display trigger as a full-width horizontal row instead of compact stacked layout */
|
||||
horizontal?: boolean;
|
||||
warnMissingCredentials?: boolean;
|
||||
agents: ChatModelsResponse;
|
||||
isLoading: boolean;
|
||||
|
|
@ -200,7 +203,7 @@ defineExpose({
|
|||
<template #trigger>
|
||||
<N8nButton
|
||||
:variant="text ? 'ghost' : 'subtle'"
|
||||
:class="$style.dropdownButton"
|
||||
:class="[$style.dropdownButton, horizontal && $style.dropdownButtonHorizontal]"
|
||||
:text="text"
|
||||
data-test-id="chat-model-selector"
|
||||
>
|
||||
|
|
@ -209,11 +212,15 @@ defineExpose({
|
|||
:size="credentialsName || !isCredentialsRequired ? 'md' : 'sm'"
|
||||
:class="$style.icon"
|
||||
/>
|
||||
<div :class="$style.selected">
|
||||
<div>
|
||||
<div :class="[$style.selected, horizontal && $style.selectedHorizontal]">
|
||||
<N8nText>
|
||||
{{ truncateBeforeLast(selectedLabel, MAX_AGENT_NAME_CHARS) }}
|
||||
</div>
|
||||
<N8nText v-if="credentialsName" size="xsmall" color="text-light">
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="credentialsName"
|
||||
:size="horizontal ? 'small' : 'xsmall'"
|
||||
color="text-light"
|
||||
>
|
||||
{{ truncateBeforeLast(credentialsName, MAX_AGENT_NAME_CHARS) }}
|
||||
</N8nText>
|
||||
<N8nText v-else-if="isCredentialsMissing" size="xsmall" color="danger">
|
||||
|
|
@ -225,7 +232,11 @@ defineExpose({
|
|||
{{ i18n.baseText('chatHub.agent.credentialsMissing') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nIcon icon="chevron-down" size="medium" />
|
||||
<N8nIcon
|
||||
:class="horizontal && $style.chevronHorizontal"
|
||||
icon="chevron-down"
|
||||
size="medium"
|
||||
/>
|
||||
</N8nButton>
|
||||
</template>
|
||||
|
||||
|
|
@ -303,6 +314,46 @@ defineExpose({
|
|||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.dropdownButtonHorizontal {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
border-radius: var(--radius--lg);
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color--foreground--shade-1);
|
||||
}
|
||||
}
|
||||
|
||||
.selectedHorizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
font-weight: var(--font-weight--bold);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.chevronHorizontal {
|
||||
align-self: flex-end;
|
||||
margin-bottom: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
margin-block: -4px;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ function resolveArtifactName(artifact: ArtifactInfo): string {
|
|||
|
||||
<style lang="scss" module>
|
||||
.reasoningTrigger {
|
||||
color: var(--text-color--subtler);
|
||||
color: var(--color--text--tint-2);
|
||||
}
|
||||
|
||||
.reasoningContent {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function handleClick(e: MouseEvent) {
|
|||
}
|
||||
|
||||
.metadata {
|
||||
color: var(--text-color--subtler);
|
||||
color: var(--color--text--tint-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -20,19 +20,16 @@ import type { IUser } from 'n8n-workflow';
|
|||
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
|
||||
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
|
||||
import { createAgent } from '@/features/agents/composables/useAgentApi';
|
||||
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
|
||||
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
|
||||
|
||||
import { N8nButton, N8nHeading, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
|
@ -361,12 +358,8 @@ const actions: Record<ActionTypes, (projectId: string) => void> = {
|
|||
uiStore.openModalWithData({ name: VARIABLE_MODAL_KEY, data: { mode: 'new' } });
|
||||
telemetry.track('User clicked header add variable button');
|
||||
},
|
||||
[ACTION_TYPES.AGENT]: async (projectId: string) => {
|
||||
const agent = await createAgent(rootStore.restApiContext, projectId, 'New Agent');
|
||||
void router.push({
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: { projectId, agentId: agent.id },
|
||||
});
|
||||
[ACTION_TYPES.AGENT]: () => {
|
||||
void router.push({ name: NEW_AGENT_VIEW });
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { flushPromises } from '@vue/test-utils';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
|
@ -46,6 +47,26 @@ vi.mock('is-emoji-supported', () => ({
|
|||
isEmojiSupported: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@/features/agents/composables/useAgentApi', () => ({
|
||||
listAllAgents: vi.fn().mockResolvedValue([
|
||||
{ id: 'agent-1', name: 'SEO Auditor' },
|
||||
{ id: 'agent-2', name: 'Inbox Sorter' },
|
||||
]),
|
||||
deleteAgent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useMessage', () => ({
|
||||
useMessage: () => ({
|
||||
confirm: vi.fn(),
|
||||
alert: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: vi.fn() }),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
||||
global: {
|
||||
plugins: [
|
||||
|
|
@ -214,4 +235,46 @@ describe('ProjectsNavigation', () => {
|
|||
// The shared menu item should not be rendered
|
||||
expect(getByTestId('project-shared-menu-item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Agents sidebar section', () => {
|
||||
beforeEach(() => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.personalProject = createTestProject({ type: 'personal' });
|
||||
});
|
||||
|
||||
it('should show agents section when agents module is active', async () => {
|
||||
settingsStore.isModuleActive = vi.fn((module: string) => module === 'agents');
|
||||
|
||||
const { queryAllByTestId, queryByTestId } = renderComponent({
|
||||
props: { collapsed: false },
|
||||
});
|
||||
|
||||
// Wait for the agents fetch to resolve
|
||||
await flushPromises();
|
||||
|
||||
expect(queryAllByTestId('agent-menu-item').length).toBeGreaterThan(0);
|
||||
expect(queryByTestId('new-agent-menu-item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide agents section when agents module is not active', () => {
|
||||
settingsStore.isModuleActive = vi.fn(() => false);
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: { collapsed: false },
|
||||
});
|
||||
|
||||
expect(queryByTestId('agent-menu-item')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-agent-menu-item')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide agents section when sidebar is collapsed', () => {
|
||||
settingsStore.isModuleActive = vi.fn((module: string) => module === 'agents');
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: { collapsed: true },
|
||||
});
|
||||
|
||||
expect(queryByTestId('new-agent-menu-item')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
<script lang="ts" setup>
|
||||
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
|
||||
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import type { IMenuItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useProjectsStore } from '../projects.store';
|
||||
import type { ProjectListItem } from '../projects.types';
|
||||
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { AGENT_BUILDER_VIEW, NEW_AGENT_VIEW } from '@/features/agents/constants';
|
||||
import { listAllAgents, deleteAgent } from '@/features/agents/composables/useAgentApi';
|
||||
import { agentsEventBus } from '@/features/agents/agents.eventBus';
|
||||
|
||||
import { N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
BetaTag,
|
||||
N8nActionDropdown,
|
||||
N8nMenuItem,
|
||||
N8nPopover,
|
||||
N8nText,
|
||||
N8nButton,
|
||||
} from '@n8n/design-system';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
|
||||
type Props = {
|
||||
collapsed: boolean;
|
||||
|
|
@ -23,11 +39,16 @@ type Props = {
|
|||
const props = defineProps<Props>();
|
||||
|
||||
const locale = useI18n();
|
||||
const route = useRoute();
|
||||
const globalEntityCreation = useGlobalEntityCreation();
|
||||
const { isCalloutDismissed, dismissCallout } = useCalloutHelpers();
|
||||
|
||||
const projectsStore = useProjectsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
const rootStore = useRootStore();
|
||||
const telemetry = useTelemetry();
|
||||
const message = useMessage();
|
||||
|
||||
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
|
||||
const isFoldersFeatureEnabled = computed(() => settingsStore.isFoldersFeatureEnabled);
|
||||
|
|
@ -45,6 +66,82 @@ const hasMultipleVerifiedUsers = computed(
|
|||
() => usersStore.allUsers.filter((user) => !user.isPendingUser).length > 1,
|
||||
);
|
||||
|
||||
// Agents sidebar section
|
||||
// Gate: isModuleActive('agents') is false when the module isn't registered.
|
||||
// The PostHog flag (0XX_first_class_agents) controls module registration on the
|
||||
// backend side — when the flag is off, 'agents' won't appear in activeModules.
|
||||
const isAgentsAvailable = computed(() => settingsStore.isModuleActive('agents'));
|
||||
const agentsList = ref<Array<{ id: string; name: string }>>([]);
|
||||
const isCoachmarkVisible = computed(
|
||||
() => isAgentsAvailable.value && !isCalloutDismissed('agents_sidebar_coachmark'),
|
||||
);
|
||||
|
||||
const activeAgentId = computed(() => {
|
||||
if (route.name === AGENT_BUILDER_VIEW) {
|
||||
return route.params.agentId as string;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const getAgentMenuItem = (agent: { id: string; name: string }): IMenuItem => ({
|
||||
id: `agent-${agent.id}`,
|
||||
label: agent.name,
|
||||
icon: { type: 'icon', value: 'robot' },
|
||||
route: {
|
||||
to: {
|
||||
name: AGENT_BUILDER_VIEW,
|
||||
params: {
|
||||
projectId: projectsStore.personalProject?.id,
|
||||
agentId: agent.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newAgentItem = computed<IMenuItem>(() => ({
|
||||
id: 'new-agent',
|
||||
label: 'New agent',
|
||||
icon: { type: 'icon', value: 'plus' },
|
||||
route: { to: { name: NEW_AGENT_VIEW } },
|
||||
}));
|
||||
|
||||
const agentActions = [{ id: 'delete', label: 'Delete' }];
|
||||
|
||||
async function fetchAgents() {
|
||||
if (!isAgentsAvailable.value) return;
|
||||
const personalProjectId = projectsStore.personalProject?.id;
|
||||
if (!personalProjectId) return;
|
||||
try {
|
||||
agentsList.value = await listAllAgents(rootStore.restApiContext, personalProjectId);
|
||||
} catch {
|
||||
agentsList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onAgentAction(action: string, agent: { id: string; name: string }) {
|
||||
if (action === 'delete') {
|
||||
const confirmed = await message.confirm(
|
||||
`Are you sure you want to delete "${agent.name}"?`,
|
||||
'Delete agent',
|
||||
{ confirmButtonText: 'Delete', cancelButtonText: 'Cancel', type: 'warning' },
|
||||
);
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
const personalProjectId = projectsStore.personalProject?.id ?? '';
|
||||
await deleteAgent(rootStore.restApiContext, personalProjectId, agent.id);
|
||||
agentsList.value = agentsList.value.filter((a) => a.id !== agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh agents list when returning from agent builder
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
if (isAgentsAvailable.value) {
|
||||
void fetchAgents();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const home = computed<IMenuItem>(() => ({
|
||||
id: 'home',
|
||||
label: locale.baseText('projects.menu.overview'),
|
||||
|
|
@ -119,10 +216,13 @@ async function onSourceControlPull() {
|
|||
onBeforeMount(async () => {
|
||||
await usersStore.fetchUsers({ filter: { isPending: false }, take: 2 });
|
||||
sourceControlEventBus.on('pull', onSourceControlPull);
|
||||
agentsEventBus.on('agentUpdated', fetchAgents);
|
||||
void fetchAgents();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sourceControlEventBus.off('pull', onSourceControlPull);
|
||||
agentsEventBus.off('agentUpdated', fetchAgents);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -167,6 +267,55 @@ onBeforeUnmount(() => {
|
|||
data-test-id="project-chat-menu-item"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="isAgentsAvailable && !props.collapsed">
|
||||
<N8nPopover :open="isCoachmarkVisible">
|
||||
<template #trigger>
|
||||
<div :class="$style.agentsLabel">
|
||||
<N8nText size="small" bold color="text-light">{{
|
||||
locale.baseText('agents.sidebar.label')
|
||||
}}</N8nText>
|
||||
<BetaTag />
|
||||
</div>
|
||||
</template>
|
||||
<div :class="$style.coachmark">
|
||||
<N8nText size="small">
|
||||
{{ locale.baseText('agents.sidebar.coachmark') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
size="mini"
|
||||
type="secondary"
|
||||
:label="locale.baseText('agents.sidebar.coachmark.dismiss')"
|
||||
@click="
|
||||
dismissCallout('agents_sidebar_coachmark');
|
||||
telemetry.track('User dismissed agents coachmark');
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</N8nPopover>
|
||||
<div :class="$style.agentItems">
|
||||
<N8nMenuItem
|
||||
:item="newAgentItem"
|
||||
:compact="false"
|
||||
:active="false"
|
||||
data-test-id="new-agent-menu-item"
|
||||
/>
|
||||
<div v-for="agent in agentsList" :key="agent.id" :class="$style.agentItem">
|
||||
<N8nMenuItem
|
||||
:item="getAgentMenuItem(agent)"
|
||||
:compact="false"
|
||||
:active="activeAgentId === agent.id"
|
||||
data-test-id="agent-menu-item"
|
||||
/>
|
||||
<div :class="$style.agentItemActions" @click.stop>
|
||||
<N8nActionDropdown
|
||||
:items="agentActions"
|
||||
activator-icon="ellipsis-vertical"
|
||||
@select="onAgentAction($event, agent)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<N8nText
|
||||
v-if="
|
||||
!props.collapsed && projectsStore.isTeamProjectFeatureEnabled && displayProjects.length > 0
|
||||
|
|
@ -259,4 +408,43 @@ onBeforeUnmount(() => {
|
|||
border-bottom: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.agentsLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: 0 var(--spacing--xs);
|
||||
margin-top: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.agentItems {
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.agentItem {
|
||||
position: relative;
|
||||
|
||||
.agentItemActions {
|
||||
position: absolute;
|
||||
right: var(--spacing--3xs);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover .agentItemActions,
|
||||
&:focus-within .agentItemActions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.coachmark {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs);
|
||||
max-width: 240px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user