mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(editor): Add paginated AI Assistant chat history (#38467)
Co-authored-by: r00gm <[email protected]>
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
| ---- | ---------- |
|
||||
| IDX_f36dea4d38fe92e0e8f44d5a56 | CREATE INDEX "IDX_f36dea4d38fe92e0e8f44d5a56" ON public.instance_ai_threads USING btree ("resourceId") |
|
||||
| IDX_instance_ai_threads_projectId | CREATE INDEX "IDX_instance_ai_threads_projectId" ON public.instance_ai_threads USING btree ("projectId") |
|
||||
| IDX_instance_ai_threads_resourceId_updatedAt_id | CREATE INDEX "IDX_instance_ai_threads_resourceId_updatedAt_id" ON public.instance_ai_threads USING btree ("resourceId", "updatedAt", id) |
|
||||
| PK_35575100e45cdedeb89ae0643e9 | CREATE UNIQUE INDEX "PK_35575100e45cdedeb89ae0643e9" ON public.instance_ai_threads USING btree (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
@@ -37,6 +37,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
|
||||
| ---- | ---------- |
|
||||
| IDX_instance_ai_threads_projectId | CREATE INDEX "IDX_instance_ai_threads_projectId" ON "instance_ai_threads" ("projectId") |
|
||||
| IDX_instance_ai_threads_resourceId | CREATE INDEX "IDX_instance_ai_threads_resourceId" ON "instance_ai_threads" ("resourceId") |
|
||||
| IDX_instance_ai_threads_resourceId_updatedAt_id | CREATE INDEX "IDX_instance_ai_threads_resourceId_updatedAt_id" ON "instance_ai_threads" ("resourceId", "updatedAt", "id") |
|
||||
| sqlite_autoindex_instance_ai_threads_1 | PRIMARY KEY (id) |
|
||||
|
||||
## Relations
|
||||
|
||||
@@ -448,6 +448,7 @@ export {
|
||||
instanceAiResourceAttachmentSchema,
|
||||
instanceAiWorkflowAttachmentSchema,
|
||||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiThreadHistoryQuery,
|
||||
INSTANCE_AI_THREAD_MESSAGES_DEFAULT_LIMIT,
|
||||
INSTANCE_AI_THREAD_MESSAGES_MAX_LIMIT,
|
||||
INSTANCE_AI_THREAD_MESSAGES_MAX_PAGE,
|
||||
@@ -545,6 +546,7 @@ export type {
|
||||
InstanceAiSSEConnectionState,
|
||||
InstanceAiThreadInfo,
|
||||
InstanceAiThreadListResponse,
|
||||
InstanceAiThreadHistoryResponse,
|
||||
InstanceAiEnsureThreadResponse,
|
||||
InstanceAiStoredMessage,
|
||||
InstanceAiThreadMessagesResponse,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
InstanceAiEnsureThreadRequest,
|
||||
findUnbackedSeedWorkflowTools,
|
||||
InstanceAiEvalRestoreThreadRequest,
|
||||
InstanceAiThreadHistoryQuery,
|
||||
InstanceAiThreadMessagesQuery,
|
||||
INSTANCE_AI_THREAD_MESSAGES_DEFAULT_LIMIT,
|
||||
INSTANCE_AI_THREAD_MESSAGES_MAX_LIMIT,
|
||||
@@ -1123,3 +1124,22 @@ describe('instanceAiHandoffContextSchema', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiThreadHistoryQuery', () => {
|
||||
it('defaults the page size and trims the search text', () => {
|
||||
expect(InstanceAiThreadHistoryQuery.parse({ search: ' Invoice ' })).toEqual({
|
||||
limit: 30,
|
||||
search: 'Invoice',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ limit: 0 },
|
||||
{ limit: 101 },
|
||||
{ search: 'x'.repeat(501) },
|
||||
{ search: 'invoice\u0000draft' },
|
||||
{ cursor: '' },
|
||||
])('rejects %o', (query) => {
|
||||
expect(InstanceAiThreadHistoryQuery.safeParse(query).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1694,6 +1694,24 @@ export interface InstanceAiThreadListResponse {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export class InstanceAiThreadHistoryQuery extends Z.class({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(30),
|
||||
// Postgres rejects NUL bytes in text parameters, so reject them here as a 400.
|
||||
search: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500)
|
||||
.refine((value) => !value.includes('\u0000'))
|
||||
.optional(),
|
||||
cursor: z.string().min(1).max(256).optional(),
|
||||
}) {}
|
||||
|
||||
export interface InstanceAiThreadHistoryResponse {
|
||||
threads: InstanceAiThreadInfo[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceAiEnsureThreadResponse {
|
||||
thread: InstanceAiThreadInfo;
|
||||
created: boolean;
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddInstanceAiThreadHistoryIndex1789131391410 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createIndex } }: MigrationContext) {
|
||||
await createIndex('instance_ai_threads', ['resourceId', 'updatedAt', 'id']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropIndex } }: MigrationContext) {
|
||||
await dropIndex('instance_ai_threads', ['resourceId', 'updatedAt', 'id']);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const mockDeleteThread = vi.fn();
|
||||
const mockDeleteThreadsByResourceIdPrefix = vi.fn();
|
||||
const mockDeleteThreadsByResourceId = vi.fn();
|
||||
const mockListThreads = vi.fn();
|
||||
const mockListThreadHistory = vi.fn();
|
||||
const mockSaveThreadWithProject = vi.fn();
|
||||
const mockGetThreadProjectId = vi.fn();
|
||||
const mockSaveMessages = vi.fn();
|
||||
@@ -18,6 +19,7 @@ const mockAgentMemory = {
|
||||
deleteThreadsByResourceIdPrefix: mockDeleteThreadsByResourceIdPrefix,
|
||||
deleteThreadsByResourceId: mockDeleteThreadsByResourceId,
|
||||
listThreads: mockListThreads,
|
||||
listThreadHistory: mockListThreadHistory,
|
||||
saveThreadWithProject: mockSaveThreadWithProject,
|
||||
getThreadProjectId: mockGetThreadProjectId,
|
||||
saveMessages: mockSaveMessages,
|
||||
@@ -123,6 +125,48 @@ function makeThread(id: string, updatedAt: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('InstanceAiMemoryService.listThreadHistory', () => {
|
||||
beforeEach(() => {
|
||||
mockListThreadHistory.mockReset();
|
||||
});
|
||||
|
||||
it('encodes the last returned row as the cursor and stops on the final page', async () => {
|
||||
const rows = ['c', 'b', 'a'].map((id) => makeThread(id, '2026-02-01T00:00:00.000Z'));
|
||||
mockListThreadHistory.mockResolvedValueOnce(rows).mockResolvedValueOnce([rows[2]]);
|
||||
const service = createService();
|
||||
const first = await service.listThreadHistory('user-1', { limit: 2, search: 'invoice' });
|
||||
expect(first.threads.map((thread) => thread.id)).toEqual(['c', 'b']);
|
||||
expect(first.hasMore).toBe(true);
|
||||
expect(first.nextCursor).not.toBeNull();
|
||||
const second = await service.listThreadHistory('user-1', {
|
||||
limit: 2,
|
||||
search: 'invoice',
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
expect(mockListThreadHistory).toHaveBeenNthCalledWith(1, 'user-1', 2, 'invoice', undefined);
|
||||
expect(mockListThreadHistory).toHaveBeenNthCalledWith(2, 'user-1', 2, 'invoice', {
|
||||
id: 'b',
|
||||
updatedAt: rows[1].updatedAt,
|
||||
});
|
||||
expect(second).toMatchObject({ hasMore: false, nextCursor: null });
|
||||
expect(second.threads.map((thread) => thread.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('returns an empty final page for a search with no matches', async () => {
|
||||
mockListThreadHistory.mockResolvedValueOnce([]);
|
||||
expect(
|
||||
await createService().listThreadHistory('user-1', { limit: 30, search: 'missing' }),
|
||||
).toEqual({ threads: [], hasMore: false, nextCursor: null });
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor before querying storage', async () => {
|
||||
await expect(
|
||||
createService().listThreadHistory('user-1', { limit: 30, cursor: 'invalid' }),
|
||||
).rejects.toThrow('Invalid thread history cursor');
|
||||
expect(mockListThreadHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiMemoryService.getRichMessages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { WithTimestamps, JsonColumn, Project } from '@n8n/db';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
|
||||
|
||||
@Entity({ name: 'instance_ai_threads' })
|
||||
@Index(['resourceId', 'updatedAt', 'id'])
|
||||
export class InstanceAiThread extends WithTimestamps {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
InstanceAiRichMessagesResponse,
|
||||
InstanceAiThreadInfo,
|
||||
InstanceAiThreadListResponse,
|
||||
InstanceAiThreadHistoryQuery,
|
||||
InstanceAiThreadHistoryResponse,
|
||||
InstanceAiThreadMessagesResponse,
|
||||
InstanceAiThreadOrigin,
|
||||
InstanceAiThreadSource,
|
||||
@@ -13,6 +15,7 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { InstanceAiConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
buildAgentTreeFromEvents,
|
||||
createSubAgentResourceIdPrefix,
|
||||
@@ -275,6 +278,48 @@ export class InstanceAiMemoryService {
|
||||
this.instanceAiConfig = globalConfig.instanceAi;
|
||||
}
|
||||
|
||||
async getThreadInfo(threadId: string): Promise<InstanceAiThreadInfo> {
|
||||
const thread = await this.agentMemory.getThread(threadId);
|
||||
if (!thread) throw new NotFoundError('Thread not found');
|
||||
return this.toThreadInfo(thread);
|
||||
}
|
||||
|
||||
async listThreadHistory(
|
||||
userId: string,
|
||||
query: InstanceAiThreadHistoryQuery,
|
||||
): Promise<InstanceAiThreadHistoryResponse> {
|
||||
let before: { updatedAt: Date; id: string } | undefined;
|
||||
if (query.cursor) {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ updatedAt: z.string().datetime(), id: z.string().min(1).max(256) })
|
||||
.parse(JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8')));
|
||||
before = { updatedAt: new Date(parsed.updatedAt), id: parsed.id };
|
||||
} catch {
|
||||
throw new BadRequestError('Invalid thread history cursor');
|
||||
}
|
||||
}
|
||||
const rows = await this.agentMemory.listThreadHistory(
|
||||
userId,
|
||||
query.limit,
|
||||
query.search,
|
||||
before,
|
||||
);
|
||||
const hasMore = rows.length > query.limit;
|
||||
const threads = rows.slice(0, query.limit).map((thread) => this.toThreadInfo(thread));
|
||||
const last = threads.at(-1);
|
||||
return {
|
||||
threads,
|
||||
hasMore,
|
||||
nextCursor:
|
||||
hasMore && last
|
||||
? Buffer.from(JSON.stringify({ updatedAt: last.updatedAt, id: last.id })).toString(
|
||||
'base64url',
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async listThreads(
|
||||
userId: string,
|
||||
page = 0,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
InstanceAiEnsureThreadRequest,
|
||||
InstanceAiPersistPendingAgentRequest,
|
||||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiThreadHistoryQuery,
|
||||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiVerifyModelRequest,
|
||||
InstanceAiVerifySandboxRequest,
|
||||
@@ -811,6 +812,25 @@ export class InstanceAiController {
|
||||
return await this.memoryService.listThreads(req.user.id);
|
||||
}
|
||||
|
||||
@Get('/threads/history')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async listThreadHistory(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query query: InstanceAiThreadHistoryQuery,
|
||||
) {
|
||||
this.requireInstanceAiEnabled();
|
||||
return await this.memoryService.listThreadHistory(req.user.id, query);
|
||||
}
|
||||
|
||||
@Get('/threads/:threadId')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async getThread(req: AuthenticatedRequest, _res: Response, @Param('threadId') threadId: string) {
|
||||
this.requireInstanceAiEnabled();
|
||||
await this.assertThreadAccess(req.user.id, threadId);
|
||||
return { thread: await this.memoryService.getThreadInfo(threadId) };
|
||||
}
|
||||
|
||||
@Post('/threads')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async ensureThread(
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { TypeORMAgentMemory } from '../../storage/typeorm-agent-memory';
|
||||
import { InstanceAiMessageRepository } from '../instance-ai-message.repository';
|
||||
import { InstanceAiObservationCursorRepository } from '../instance-ai-observation-cursor.repository';
|
||||
import { InstanceAiObservationLockRepository } from '../instance-ai-observation-lock.repository';
|
||||
import { InstanceAiObservationRepository } from '../instance-ai-observation.repository';
|
||||
import { InstanceAiResourceRepository } from '../instance-ai-resource.repository';
|
||||
import { InstanceAiThreadRepository } from '../instance-ai-thread.repository';
|
||||
|
||||
describe('Instance AI thread history', () => {
|
||||
let repository: InstanceAiThreadRepository;
|
||||
let memory: TypeORMAgentMemory;
|
||||
let projectId: string;
|
||||
const ids = [1, 2, 3, 4].map((id) => '00000000-0000-4000-8000-' + String(id).padStart(12, '0'));
|
||||
const older = new Date('2026-01-01T00:00:00.000Z');
|
||||
const newer = new Date('2026-02-01T00:00:00.000Z');
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['instance-ai']);
|
||||
await testDb.init();
|
||||
repository = Container.get(InstanceAiThreadRepository);
|
||||
projectId = (await createTeamProject()).id;
|
||||
memory = new TypeORMAgentMemory(
|
||||
repository,
|
||||
Container.get(InstanceAiMessageRepository),
|
||||
Container.get(InstanceAiResourceRepository),
|
||||
Container.get(InstanceAiObservationRepository),
|
||||
Container.get(InstanceAiObservationCursorRepository),
|
||||
Container.get(InstanceAiObservationLockRepository),
|
||||
Container.get(Logger),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await repository.delete({});
|
||||
await repository.insert(
|
||||
ids.map((id, index) => ({
|
||||
id,
|
||||
projectId,
|
||||
resourceId: 'user-1',
|
||||
title: index === 1 ? 'Invoice 100%_draft' : 'Invoice',
|
||||
createdAt: older,
|
||||
updatedAt: index === 0 ? older : newer,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
it('uses timestamp and id cursors with a lookahead row and an empty final page', async () => {
|
||||
const first = await repository.listHistoryPage('user-1', 2);
|
||||
expect(first.map((thread) => thread.id)).toEqual([ids[3], ids[2], ids[1]]);
|
||||
const second = await repository.listHistoryPage('user-1', 2, undefined, first[1]);
|
||||
expect(second.map((thread) => thread.id)).toEqual([ids[1], ids[0]]);
|
||||
expect(await repository.listHistoryPage('user-1', 2, undefined, second[1])).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters case-insensitively, treats wildcard characters literally, and scopes to the user', async () => {
|
||||
await repository.insert({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
resourceId: 'other-user',
|
||||
title: 'Invoice',
|
||||
createdAt: older,
|
||||
updatedAt: newer,
|
||||
});
|
||||
expect(await repository.listHistoryPage('user-1', 10, 'missing')).toEqual([]);
|
||||
expect(
|
||||
(await repository.listHistoryPage('user-1', 10, 'iNvOiCe')).map((row) => row.id),
|
||||
).toEqual([ids[3], ids[2], ids[1], ids[0]]);
|
||||
expect((await repository.listHistoryPage('user-1', 10, '%_')).map((row) => row.id)).toEqual([
|
||||
ids[1],
|
||||
]);
|
||||
const first = await repository.listHistoryPage('user-1', 2, 'invoice');
|
||||
expect(
|
||||
(await repository.listHistoryPage('user-1', 2, 'invoice', first[1])).map((row) => row.id),
|
||||
).toEqual([ids[1], ids[0]]);
|
||||
});
|
||||
|
||||
it('advances activity after saving messages without shifting the remaining cursor page', async () => {
|
||||
const first = await repository.listHistoryPage('user-1', 2);
|
||||
await memory.saveMessages({
|
||||
threadId: ids[3],
|
||||
resourceId: 'user-1',
|
||||
messages: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
createdAt: older,
|
||||
},
|
||||
],
|
||||
});
|
||||
const updated = await repository.findOneByOrFail({ id: ids[3] });
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThan(newer.getTime());
|
||||
expect((await repository.findOneByOrFail({ id: ids[2] })).updatedAt).toEqual(newer);
|
||||
expect(
|
||||
(await repository.listHistoryPage('user-1', 2, undefined, first[1])).map((row) => row.id),
|
||||
).toEqual([ids[1], ids[0]]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Thread } from '@n8n/agents';
|
||||
import { BaseRepository, TransactionRunner } from '@n8n/db';
|
||||
import { BaseRepository, TransactionRunner, escapeLike, LIKE_ESCAPE_CLAUSE } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { DataSource, LessThan, Raw } from '@n8n/typeorm';
|
||||
|
||||
import { InstanceAiThread } from '../entities/instance-ai-thread.entity';
|
||||
|
||||
@@ -45,4 +45,37 @@ export class InstanceAiThreadRepository extends BaseRepository<InstanceAiThread>
|
||||
return { ...current, ...patch, title: saved.title || undefined, updatedAt: saved.updatedAt };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a user's threads, newest activity first, plus one lookahead row so the
|
||||
* caller can tell whether another page exists. `before` is the last row of the previous
|
||||
* page: keyset pagination on (updatedAt, id) stays stable while threads get reordered.
|
||||
*/
|
||||
async listHistoryPage(
|
||||
resourceId: string,
|
||||
limit: number,
|
||||
search?: string,
|
||||
before?: { updatedAt: Date; id: string },
|
||||
) {
|
||||
const base = {
|
||||
resourceId,
|
||||
...(search
|
||||
? {
|
||||
title: Raw((alias) => `LOWER(${alias}) LIKE :search ${LIKE_ESCAPE_CLAUSE}`, {
|
||||
search: `%${escapeLike(search.toLowerCase())}%`,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return await this.find({
|
||||
where: before
|
||||
? [
|
||||
{ ...base, updatedAt: LessThan(before.updatedAt) },
|
||||
{ ...base, updatedAt: before.updatedAt, id: LessThan(before.id) },
|
||||
]
|
||||
: base,
|
||||
order: { updatedAt: 'DESC', id: 'DESC' },
|
||||
take: limit + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,15 @@ export class TypeORMAgentMemory
|
||||
return thread ? toThread(thread) : null;
|
||||
}
|
||||
|
||||
async listThreadHistory(
|
||||
resourceId: string,
|
||||
limit: number,
|
||||
search?: string,
|
||||
before?: { updatedAt: Date; id: string },
|
||||
): Promise<Thread[]> {
|
||||
return (await this.threadRepo.listHistoryPage(resourceId, limit, search, before)).map(toThread);
|
||||
}
|
||||
|
||||
async listThreads(args: {
|
||||
filter?: { resourceId?: string };
|
||||
perPage?: number;
|
||||
@@ -520,6 +529,7 @@ export class TypeORMAgentMemory
|
||||
});
|
||||
|
||||
await this.messageRepo.save(entities);
|
||||
await this.threadRepo.update(args.threadId, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
async deleteMessages(messageIds: string[]): Promise<void> {
|
||||
|
||||
@@ -6911,6 +6911,14 @@
|
||||
"instanceAi.sidebar.group.older": "Older",
|
||||
"instanceAi.sidebar.renameThread": "Rename",
|
||||
"instanceAi.sidebar.deleteThread": "Delete",
|
||||
"instanceAi.threads.searchPlaceholder": "Search conversations",
|
||||
"instanceAi.threads.actions": "Conversation actions",
|
||||
"instanceAi.threads.rename": "Rename conversation",
|
||||
"instanceAi.threads.renameError": "Couldn't rename the chat. Try again.",
|
||||
"instanceAi.threads.loading": "Loading chats…",
|
||||
"instanceAi.threads.loadError": "Couldn't load chats. Try again.",
|
||||
"instanceAi.threads.backToAssistant": "Back to AI Assistant",
|
||||
"instanceAi.threads.noSearchResults": "No conversations match your search",
|
||||
"instanceAi.connections.add.computerUse": "Computer Use",
|
||||
"instanceAi.connections.add.browserUse": "Browser Use",
|
||||
"instanceAi.connections.disconnectError.title": "Gateway disconnect failed",
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
||||
import { countAttachedNodes } from './utils/buildNodesAttachment';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { provideThread, useInstanceAiStore } from './instanceAi.store';
|
||||
import {
|
||||
getAgentBuilderTargetFromThreadMetadata,
|
||||
@@ -895,7 +896,7 @@ function reconnectThreadAfterHydration(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the route's :threadId against the loaded thread list, then connect
|
||||
// Resolve the route's thread independently of the paginated history, then connect
|
||||
// this route-scoped runtime. Route changes remount this component, so no
|
||||
// store-level "active thread" state is needed here.
|
||||
async function syncRouteToStore() {
|
||||
@@ -904,16 +905,24 @@ async function syncRouteToStore() {
|
||||
// submit cannot race past it while the thread list is still loading.
|
||||
pendingComposerContext.value = getPendingHandoffContext(requestedThreadId);
|
||||
pendingComposerDraft.value = getPendingComposerDraft(requestedThreadId);
|
||||
if (!store.threads.length) {
|
||||
await store.loadThreads();
|
||||
}
|
||||
// User may have navigated elsewhere while we awaited
|
||||
if (requestedThreadId !== props.threadId) return;
|
||||
if (!store.threads.some((t) => t.id === requestedThreadId)) {
|
||||
clearPendingThreadHandoff(requestedThreadId);
|
||||
void router.replace({ name: INSTANCE_AI_VIEW });
|
||||
return;
|
||||
try {
|
||||
await store.loadThread(requestedThreadId);
|
||||
} catch (error) {
|
||||
if (router.currentRoute.value.params.threadId !== requestedThreadId) return;
|
||||
if (
|
||||
error instanceof ResponseError &&
|
||||
(error.httpStatusCode === 403 || error.httpStatusCode === 404)
|
||||
) {
|
||||
clearPendingThreadHandoff(requestedThreadId);
|
||||
void router.replace({ name: INSTANCE_AI_VIEW });
|
||||
} else {
|
||||
toast.showError(error, i18n.baseText('generic.error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (router.currentRoute.value.params.threadId !== requestedThreadId) return;
|
||||
if (thread.sseState === 'disconnected') {
|
||||
reconnectThreadAfterHydration();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
N8nActionDropdown,
|
||||
N8nButton,
|
||||
N8nHeading,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInput,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import type { ActionDropdownItem } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import type { InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import PageViewLayout from '@/app/components/layouts/PageViewLayout.vue';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { formatTimeAgo } from '@/app/utils/formatters/dateFormatter';
|
||||
import { clearPendingThreadHandoff } from './composables/useInstanceAiHandoff';
|
||||
import { useInstanceAiThreadHistory } from './composables/useInstanceAiThreadHistory';
|
||||
import { INSTANCE_AI_THREAD_VIEW, INSTANCE_AI_VIEW } from './constants';
|
||||
import { useInstanceAiStore } from './instanceAi.store';
|
||||
|
||||
const store = useInstanceAiStore();
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
useDocumentTitle().set(i18n.baseText('instanceAi.sidebar.chatHistory'));
|
||||
|
||||
const { history, search, listRef, sentinelRef, loadMore } = useInstanceAiThreadHistory();
|
||||
const editingThreadId = ref<string | null>(null);
|
||||
const editingTitle = ref('');
|
||||
|
||||
const threadActions: Array<ActionDropdownItem<'rename' | 'delete'>> = [
|
||||
{ id: 'rename', label: i18n.baseText('instanceAi.sidebar.renameThread'), icon: 'pencil' },
|
||||
{ id: 'delete', label: i18n.baseText('instanceAi.sidebar.deleteThread'), icon: 'trash-2' },
|
||||
];
|
||||
|
||||
function startRename(thread: InstanceAiThreadSummary) {
|
||||
editingThreadId.value = thread.id;
|
||||
editingTitle.value = thread.title;
|
||||
}
|
||||
|
||||
function focusRenameInput(el: unknown) {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRename(thread: InstanceAiThreadSummary) {
|
||||
// Enter removes the input, which can also fire blur. Rename once.
|
||||
if (editingThreadId.value !== thread.id) return;
|
||||
editingThreadId.value = null;
|
||||
const title = editingTitle.value.trim();
|
||||
if (!title || title === thread.title) return;
|
||||
try {
|
||||
await store.renameThread(thread.id, title);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('instanceAi.threads.renameError'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleThreadAction(action: string, thread: InstanceAiThreadSummary) {
|
||||
if (action === 'rename') startRename(thread);
|
||||
if (action === 'delete' && (await store.deleteThread(thread.id))) {
|
||||
clearPendingThreadHandoff(thread.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageViewLayout full-width data-test-id="instance-ai-threads-view">
|
||||
<div :class="$style.page">
|
||||
<div :class="$style.header">
|
||||
<N8nIconButton
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
:aria-label="i18n.baseText('instanceAi.threads.backToAssistant')"
|
||||
@click="router.push({ name: INSTANCE_AI_VIEW })"
|
||||
/>
|
||||
<N8nHeading :class="$style.heading" tag="h1" size="xlarge" bold>
|
||||
{{ i18n.baseText('instanceAi.sidebar.chatHistory') }}
|
||||
</N8nHeading>
|
||||
<N8nInput
|
||||
v-model="search"
|
||||
:class="$style.search"
|
||||
size="small"
|
||||
clearable
|
||||
:maxlength="500"
|
||||
:placeholder="i18n.baseText('instanceAi.threads.searchPlaceholder')"
|
||||
data-test-id="instance-ai-threads-search"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon icon="search" size="small" />
|
||||
</template>
|
||||
</N8nInput>
|
||||
<N8nButton size="small" icon="plus" @click="router.push({ name: INSTANCE_AI_VIEW })">
|
||||
{{ i18n.baseText('instanceAi.thread.new') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
|
||||
<div ref="listRef" :class="$style.list">
|
||||
<div
|
||||
v-for="thread in history.threads"
|
||||
:key="thread.id"
|
||||
:class="$style.row"
|
||||
data-test-id="instance-ai-history-thread"
|
||||
>
|
||||
<input
|
||||
v-if="editingThreadId === thread.id"
|
||||
:ref="focusRenameInput"
|
||||
v-model="editingTitle"
|
||||
:class="$style.renameInput"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
@keydown.enter="confirmRename(thread)"
|
||||
@keydown.escape="editingThreadId = null"
|
||||
@blur="confirmRename(thread)"
|
||||
/>
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
:to="{ name: INSTANCE_AI_THREAD_VIEW, params: { threadId: thread.id } }"
|
||||
:class="$style.link"
|
||||
:title="thread.title"
|
||||
@dblclick.prevent="startRename(thread)"
|
||||
>
|
||||
<N8nIcon :class="$style.icon" icon="message-circle" size="medium" />
|
||||
<N8nText :class="$style.title" size="medium">{{ thread.title }}</N8nText>
|
||||
</RouterLink>
|
||||
<N8nText size="small" color="text-light">{{ formatTimeAgo(thread.updatedAt) }}</N8nText>
|
||||
<N8nActionDropdown
|
||||
:items="threadActions"
|
||||
:class="$style.actions"
|
||||
placement="bottom-end"
|
||||
@select="handleThreadAction($event, thread)"
|
||||
>
|
||||
<template #activator>
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
icon="ellipsis-vertical"
|
||||
:aria-label="i18n.baseText('instanceAi.threads.actions')"
|
||||
/>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="history.loading" :class="$style.status" role="status">
|
||||
<N8nText color="text-light">{{ i18n.baseText('instanceAi.threads.loading') }}</N8nText>
|
||||
</div>
|
||||
<div v-else-if="history.error" :class="$style.status" role="alert">
|
||||
<N8nText>{{ i18n.baseText('instanceAi.threads.loadError') }}</N8nText>
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
data-test-id="instance-ai-threads-retry"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div v-else-if="history.hasMore" ref="sentinelRef" :class="$style.sentinel" />
|
||||
<div v-else-if="history.threads.length === 0" :class="$style.status">
|
||||
<N8nText color="text-light">
|
||||
{{
|
||||
i18n.baseText(
|
||||
history.search
|
||||
? 'instanceAi.threads.noSearchResults'
|
||||
: 'instanceAi.sidebar.noThreads',
|
||||
)
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageViewLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: var(--content-container--width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border-bottom: var(--border);
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:has([aria-expanded='true']) {
|
||||
background-color: var(--color--background--light-1);
|
||||
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--color--text) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
|
||||
// Touch devices have no hover: keep the menu button visible.
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.renameInput {
|
||||
flex: 1;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
color: var(--color--text);
|
||||
background: var(--color--background--light-2);
|
||||
border: var(--border);
|
||||
border-color: var(--color--primary);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--lg);
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
+7
-6
@@ -1123,13 +1123,13 @@ describe('InstanceAiThreadView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('prefills pending composer state before the thread list finishes loading', async () => {
|
||||
it('prefills pending composer state before the thread finishes loading', async () => {
|
||||
store.threads = [];
|
||||
let resolveThreadList!: (loaded: boolean) => void;
|
||||
store.loadThreads.mockImplementation(
|
||||
let resolveThread!: () => void;
|
||||
store.loadThread.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveThreadList = resolve;
|
||||
resolveThread = resolve;
|
||||
}),
|
||||
);
|
||||
localStorageState.store.set(
|
||||
@@ -1145,7 +1145,7 @@ describe('InstanceAiThreadView', () => {
|
||||
const { getByTestId } = renderView({ props: { threadId: 'thread-1' } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.loadThreads).toHaveBeenCalledWith();
|
||||
expect(store.loadThread).toHaveBeenCalledWith('thread-1');
|
||||
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('Preview session');
|
||||
expect(getByTestId('instance-ai-input-draft')).toHaveTextContent('Fix the failed tool');
|
||||
});
|
||||
@@ -1158,7 +1158,7 @@ describe('InstanceAiThreadView', () => {
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
] as typeof store.threads;
|
||||
resolveThreadList(true);
|
||||
resolveThread();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
@@ -1649,6 +1649,7 @@ describe('InstanceAiThreadView', () => {
|
||||
});
|
||||
|
||||
it('connects the route thread when navigating to a known thread', async () => {
|
||||
mockRouteState.params.threadId = 'thread-2';
|
||||
thread.sseState = 'disconnected';
|
||||
store.threads = [
|
||||
...store.threads,
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { N8nInput } from '@n8n/design-system';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick, reactive } from 'vue';
|
||||
import InstanceAiThreadsView from '../InstanceAiThreadsView.vue';
|
||||
|
||||
type Row = { id: string; title: string; createdAt: string; updatedAt: string };
|
||||
|
||||
function emptyHistory(search = '') {
|
||||
return { search, threads: [] as Row[], hasMore: true, loading: false, error: false };
|
||||
}
|
||||
|
||||
const store = reactive({
|
||||
threadHistory: emptyHistory(),
|
||||
loadThreadHistoryPage: vi.fn(),
|
||||
resetThreadHistory: vi.fn((search = '') => {
|
||||
store.threadHistory = emptyHistory(search);
|
||||
}),
|
||||
renameThread: vi.fn(),
|
||||
deleteThread: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock('../instanceAi.store', () => ({ useInstanceAiStore: () => store }));
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError: vi.fn() }) }));
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({ set: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/app/composables/useIntersectionObserver', () => ({
|
||||
useIntersectionObserver: () => ({ observe: vi.fn() }),
|
||||
}));
|
||||
|
||||
function mountView() {
|
||||
return shallowMount(InstanceAiThreadsView, {
|
||||
global: {
|
||||
renderStubDefaultSlot: true,
|
||||
stubs: { RouterLink: { template: '<a><slot /></a>' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('InstanceAiThreadsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
store.resetThreadHistory();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('loads the first page on mount and renders the rows', async () => {
|
||||
const wrapper = mountView();
|
||||
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.threadHistory.threads = [
|
||||
{ id: 'a', title: 'Alpha', createdAt: '2026-01-01', updatedAt: '2026-01-02' },
|
||||
{ id: 'b', title: 'Beta', createdAt: '2026-01-01', updatedAt: '2026-01-01' },
|
||||
];
|
||||
store.threadHistory.hasMore = false;
|
||||
await nextTick();
|
||||
|
||||
const rows = wrapper.findAll('[data-test-id="instance-ai-history-thread"]');
|
||||
expect(rows.map((row) => row.text())).toEqual([
|
||||
expect.stringContaining('Alpha'),
|
||||
expect.stringContaining('Beta'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers a retry after a failed page', async () => {
|
||||
const wrapper = mountView();
|
||||
store.threadHistory.error = true;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't load chats");
|
||||
await wrapper.find('[data-test-id="instance-ai-threads-retry"]').trigger('click');
|
||||
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('restarts the list with the debounced search text', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.findComponent(N8nInput).vm.$emit('update:modelValue', ' invoice ');
|
||||
await nextTick();
|
||||
expect(store.resetThreadHistory).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(store.resetThreadHistory).toHaveBeenCalledWith('invoice');
|
||||
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drops a search that is still debouncing when the page is left', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.findComponent(N8nInput).vm.$emit('update:modelValue', 'invoice');
|
||||
await nextTick();
|
||||
wrapper.unmount();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(store.resetThreadHistory).toHaveBeenCalledTimes(1);
|
||||
expect(store.threadHistory.search).toBe('');
|
||||
expect(store.loadThreadHistoryPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears the history when the page is left', () => {
|
||||
mountView().unmount();
|
||||
expect(store.resetThreadHistory).toHaveBeenCalledTimes(1);
|
||||
expect(store.threadHistory.search).toBe('');
|
||||
});
|
||||
});
|
||||
+80
-2
@@ -1,9 +1,18 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ensureThread } from '../instanceAi.api';
|
||||
import { deleteThread as deleteThreadApi } from '../instanceAi.memory.api';
|
||||
import {
|
||||
deleteThread as deleteThreadApi,
|
||||
fetchThreadHistory,
|
||||
fetchThread,
|
||||
renameThread as renameThreadApi,
|
||||
} from '../instanceAi.memory.api';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
import { UNLIMITED_CREDITS, type InstanceAiThreadSummary } from '@n8n/api-types';
|
||||
import {
|
||||
UNLIMITED_CREDITS,
|
||||
type InstanceAiThreadHistoryResponse,
|
||||
type InstanceAiThreadSummary,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn().mockReturnValue({
|
||||
@@ -48,6 +57,8 @@ vi.mock('../instanceAi.api', () => ({
|
||||
|
||||
vi.mock('../instanceAi.memory.api', () => ({
|
||||
fetchThreads: vi.fn().mockResolvedValue({ threads: [], total: 0, page: 1, hasMore: false }),
|
||||
fetchThread: vi.fn(),
|
||||
fetchThreadHistory: vi.fn().mockResolvedValue({ threads: [], nextCursor: null, hasMore: false }),
|
||||
fetchThreadMessages: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ threadId: 'thread-1', messages: [], nextEventId: 0 }),
|
||||
@@ -91,6 +102,73 @@ describe('useInstanceAiStore - runtime registry', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function historyThread(id: string) {
|
||||
return { id, title: id, resourceId: 'user', createdAt: '2026-01-01', updatedAt: '2026-01-01' };
|
||||
}
|
||||
|
||||
it('pages the chat history once per row and drops a response that predates a reset', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
vi.mocked(fetchThreadHistory)
|
||||
.mockResolvedValueOnce({
|
||||
threads: [historyThread('a'), historyThread('b')],
|
||||
nextCursor: 'cursor-1',
|
||||
hasMore: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
threads: [historyThread('b'), historyThread('c')],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
await store.loadThreadHistoryPage();
|
||||
await store.loadThreadHistoryPage();
|
||||
expect(fetchThreadHistory).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
limit: 30,
|
||||
search: undefined,
|
||||
cursor: 'cursor-1',
|
||||
});
|
||||
expect(store.threadHistory.threads.map((thread) => thread.id)).toEqual(['a', 'b', 'c']);
|
||||
expect(store.threadHistory.hasMore).toBe(false);
|
||||
|
||||
const late = Promise.withResolvers<InstanceAiThreadHistoryResponse>();
|
||||
vi.mocked(fetchThreadHistory).mockReturnValueOnce(late.promise);
|
||||
store.resetThreadHistory('old');
|
||||
const pending = store.loadThreadHistoryPage();
|
||||
store.resetThreadHistory('new');
|
||||
late.resolve({ threads: [historyThread('late')], nextCursor: null, hasMore: false });
|
||||
await pending;
|
||||
expect(store.threadHistory).toMatchObject({ search: 'new', threads: [], loading: false });
|
||||
});
|
||||
|
||||
it('renames and deletes threads on the history page and rolls back a failed rename', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
vi.mocked(fetchThreadHistory).mockResolvedValueOnce({
|
||||
threads: [historyThread('a'), historyThread('b')],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
await store.loadThreadHistoryPage();
|
||||
|
||||
await store.renameThread('a', 'Renamed');
|
||||
expect(renameThreadApi).toHaveBeenCalledWith(expect.anything(), 'a', 'Renamed');
|
||||
expect(store.threadHistory.threads[0].title).toBe('Renamed');
|
||||
|
||||
vi.mocked(renameThreadApi).mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(store.renameThread('a', 'Rejected')).rejects.toThrow('offline');
|
||||
expect(store.threadHistory.threads[0].title).toBe('Renamed');
|
||||
|
||||
await store.deleteThread('b');
|
||||
expect(mockDeleteThread).toHaveBeenCalledWith(expect.anything(), 'b');
|
||||
expect(store.threadHistory.threads.map((thread) => thread.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('loadThread adds a thread the sidebar list does not hold, once', async () => {
|
||||
const store = useInstanceAiStore();
|
||||
vi.mocked(fetchThread).mockResolvedValue({ thread: historyThread('old') });
|
||||
await store.loadThread('old');
|
||||
await store.loadThread('old');
|
||||
expect(store.threads.map((thread) => thread.id)).toEqual(['old']);
|
||||
});
|
||||
|
||||
it('returns the same runtime for the same thread id', () => {
|
||||
const store = useInstanceAiStore();
|
||||
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { refDebounced } from '@vueuse/core';
|
||||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
|
||||
import { DEBOUNCE_TIME } from '@/app/constants';
|
||||
import { useInstanceAiStore } from '../instanceAi.store';
|
||||
|
||||
/**
|
||||
* Server-side searched, cursor-paged chat list for one mounted list at a time.
|
||||
* Bind `search` to the input, `listRef` to the scroll container and `sentinelRef` to an
|
||||
* element after the last row. Loads on mount and clears the store state on unmount.
|
||||
*/
|
||||
export function useInstanceAiThreadHistory() {
|
||||
const store = useInstanceAiStore();
|
||||
const history = computed(() => store.threadHistory);
|
||||
const search = ref('');
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function loadMore() {
|
||||
void store.loadThreadHistoryPage();
|
||||
}
|
||||
|
||||
// A component-scoped watcher stops on unmount, so a search that is still debouncing
|
||||
// when the user leaves does not reload the shared state.
|
||||
const debouncedSearch = refDebounced(search, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
|
||||
watch(debouncedSearch, (value) => {
|
||||
store.resetThreadHistory(value.trim());
|
||||
loadMore();
|
||||
});
|
||||
|
||||
// The observer fires once per observe() call. Re-arm it after every page so a sentinel that
|
||||
// is still in view (short list in a tall window) loads the next page right away.
|
||||
const { observe } = useIntersectionObserver({ root: listRef, onIntersect: loadMore });
|
||||
watch(
|
||||
[sentinelRef, () => history.value.loading],
|
||||
([sentinel, loading]) => {
|
||||
if (sentinel && !loading) observe(sentinel);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
onMounted(loadMore);
|
||||
onBeforeUnmount(() => store.resetThreadHistory());
|
||||
|
||||
return { history, search, listRef, sentinelRef, loadMore };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { INSTANCE_AI_THREAD_SOURCES, type InstanceAiThreadSource } from '@n8n/ap
|
||||
|
||||
export const INSTANCE_AI_VIEW = 'InstanceAi';
|
||||
export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread';
|
||||
export const INSTANCE_AI_THREADS_VIEW = 'InstanceAiThreads';
|
||||
export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings';
|
||||
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
|
||||
/**
|
||||
@@ -57,6 +58,7 @@ export function isInstanceAiThreadSource(value: unknown): value is InstanceAiThr
|
||||
const INSTANCE_AI_CHAT_ROUTE_NAMES: ReadonlySet<string> = new Set([
|
||||
INSTANCE_AI_VIEW,
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
INSTANCE_AI_THREADS_VIEW,
|
||||
INSTANCE_AI_NEW_VIEW,
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import type {
|
||||
InstanceAiThreadInfo,
|
||||
InstanceAiThreadListResponse,
|
||||
InstanceAiThreadHistoryQuery,
|
||||
InstanceAiThreadHistoryResponse,
|
||||
InstanceAiRichMessagesResponse,
|
||||
InstanceAiThreadStatusResponse,
|
||||
InstanceAiRunDebugResponse,
|
||||
@@ -16,6 +18,20 @@ export async function fetchThreads(
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/threads');
|
||||
}
|
||||
|
||||
export async function fetchThreadHistory(
|
||||
context: IRestApiContext,
|
||||
query: InstanceAiThreadHistoryQuery,
|
||||
): Promise<InstanceAiThreadHistoryResponse> {
|
||||
return await makeRestApiRequest(context, 'GET', '/instance-ai/threads/history', query);
|
||||
}
|
||||
|
||||
export async function fetchThread(
|
||||
context: IRestApiContext,
|
||||
threadId: string,
|
||||
): Promise<{ thread: InstanceAiThreadInfo }> {
|
||||
return await makeRestApiRequest(context, 'GET', `/instance-ai/threads/${threadId}`);
|
||||
}
|
||||
|
||||
export async function deleteThread(context: IRestApiContext, threadId: string): Promise<void> {
|
||||
await makeRestApiRequest(context, 'DELETE', `/instance-ai/threads/${threadId}`);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useToast } from '@n8n/composables/useToast';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import {
|
||||
UNLIMITED_CREDITS,
|
||||
type InstanceAiThreadHistoryResponse,
|
||||
type InstanceAiThreadInfo,
|
||||
type InstanceAiThreadSummary,
|
||||
type InstanceAiAttachment,
|
||||
type InstanceAiNodesAttachment,
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
|
||||
import {
|
||||
fetchThreads as fetchThreadsApi,
|
||||
fetchThreadHistory,
|
||||
fetchThread,
|
||||
deleteThread as deleteThreadApi,
|
||||
renameThread as renameThreadApi,
|
||||
updateThreadMetadata as updateThreadMetadataApi,
|
||||
@@ -30,6 +34,16 @@ export type { PendingConfirmationItem, ThreadRuntime } from './instanceAi.thread
|
||||
|
||||
type InstanceAiCreditsPushData = Extract<PushMessage, { type: 'updateInstanceAiCredits' }>['data'];
|
||||
|
||||
const THREAD_HISTORY_PAGE_SIZE = 30;
|
||||
|
||||
const emptyThreadHistory = (search = '') => ({
|
||||
search,
|
||||
threads: [] as InstanceAiThreadSummary[],
|
||||
hasMore: true,
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
const rootStore = useRootStore();
|
||||
const instanceAiSettingsStore = useInstanceAiSettingsStore();
|
||||
@@ -39,6 +53,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
|
||||
// --- Instance-level state ---
|
||||
const threads = ref<InstanceAiThreadSummary[]>([]);
|
||||
// The chat history page: cursor-paginated and searchable, kept apart from the sidebar list.
|
||||
const threadHistory = ref(emptyThreadHistory());
|
||||
const debugMode = ref(false);
|
||||
// Credits are instance-level state (not per-thread). Re-fetched on mount via fetchCredits(),
|
||||
// and updated in real-time via the 'updateInstanceAiCredits' push event.
|
||||
@@ -52,8 +68,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
const runtimes = shallowReactive(new Map<string, ThreadRuntime>());
|
||||
const runtimeHooks = {
|
||||
onTitleUpdated: (threadId, title) => {
|
||||
const thread = threads.value.find((t) => t.id === threadId);
|
||||
if (thread) thread.title = title;
|
||||
for (const thread of localThreadEntries(threadId)) thread.title = title;
|
||||
},
|
||||
// Refresh thread list to pick up auto-generated titles
|
||||
onRunFinish: () => {
|
||||
@@ -163,6 +178,21 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
|
||||
// --- Thread list & lifecycle ---
|
||||
|
||||
function toThreadSummary(thread: InstanceAiThreadInfo): InstanceAiThreadSummary {
|
||||
return {
|
||||
id: thread.id,
|
||||
title: thread.title || NEW_CONVERSATION_TITLE,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
metadata: thread.metadata ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every local copy of a thread; the sidebar list and the history page can both hold one. */
|
||||
function localThreadEntries(threadId: string): InstanceAiThreadSummary[] {
|
||||
return [...threads.value, ...threadHistory.value.threads].filter((t) => t.id === threadId);
|
||||
}
|
||||
|
||||
async function loadThreads(): Promise<boolean> {
|
||||
try {
|
||||
const result = await fetchThreadsApi(rootStore.restApiContext);
|
||||
@@ -173,14 +203,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
// (e.g. a freshly created thread that hasn't been persisted yet)
|
||||
const serverIds = new Set(result.threads.map((t) => t.id));
|
||||
const localOnly = threads.value.filter((t) => !serverIds.has(t.id));
|
||||
const serverThreads: InstanceAiThreadSummary[] = result.threads.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title || NEW_CONVERSATION_TITLE,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
metadata: t.metadata ?? undefined,
|
||||
}));
|
||||
threads.value = [...localOnly, ...serverThreads];
|
||||
threads.value = [...localOnly, ...result.threads.map(toThreadSummary)];
|
||||
return true;
|
||||
} catch {
|
||||
// Silently ignore — threads will remain client-side only
|
||||
@@ -188,6 +211,57 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a thread the sidebar list does not hold, e.g. an older one opened by URL. */
|
||||
async function loadThread(threadId: string): Promise<void> {
|
||||
const { thread } = await fetchThread(rootStore.restApiContext, threadId);
|
||||
persistedThreadIds.add(thread.id);
|
||||
if (!threads.value.some((t) => t.id === thread.id)) {
|
||||
threads.value.push(toThreadSummary(thread));
|
||||
}
|
||||
}
|
||||
|
||||
let threadHistoryCursor: string | undefined;
|
||||
// Bumped by every reset so a response still in flight for the old state is dropped.
|
||||
let threadHistoryRequest = 0;
|
||||
|
||||
function resetThreadHistory(search = ''): void {
|
||||
threadHistoryRequest++;
|
||||
threadHistoryCursor = undefined;
|
||||
threadHistory.value = emptyThreadHistory(search);
|
||||
}
|
||||
|
||||
async function loadThreadHistoryPage(): Promise<void> {
|
||||
const history = threadHistory.value;
|
||||
if (history.loading || !history.hasMore) return;
|
||||
const request = threadHistoryRequest;
|
||||
history.loading = true;
|
||||
history.error = false;
|
||||
let result: InstanceAiThreadHistoryResponse | undefined;
|
||||
try {
|
||||
result = await fetchThreadHistory(rootStore.restApiContext, {
|
||||
limit: THREAD_HISTORY_PAGE_SIZE,
|
||||
search: history.search || undefined,
|
||||
cursor: threadHistoryCursor,
|
||||
});
|
||||
} catch {
|
||||
// Reported through `error` below
|
||||
}
|
||||
if (request !== threadHistoryRequest) return;
|
||||
history.loading = false;
|
||||
if (!result) {
|
||||
history.error = true;
|
||||
return;
|
||||
}
|
||||
for (const thread of result.threads) {
|
||||
persistedThreadIds.add(thread.id);
|
||||
}
|
||||
// A thread that gets activity while paging moves onto a later page; keep each row once.
|
||||
const known = new Set(history.threads.map((t) => t.id));
|
||||
history.threads.push(...result.threads.filter((t) => !known.has(t.id)).map(toThreadSummary));
|
||||
threadHistoryCursor = result.nextCursor ?? undefined;
|
||||
history.hasMore = result.hasMore;
|
||||
}
|
||||
|
||||
async function syncThread(
|
||||
threadId: string,
|
||||
projectId: string,
|
||||
@@ -218,13 +292,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
threads.value.unshift({
|
||||
id: result.thread.id,
|
||||
title: result.thread.title || NEW_CONVERSATION_TITLE,
|
||||
createdAt: result.thread.createdAt,
|
||||
updatedAt: result.thread.updatedAt,
|
||||
metadata: result.thread.metadata ?? undefined,
|
||||
});
|
||||
threads.value.unshift(toThreadSummary(result.thread));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,20 +322,27 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
|
||||
// Remove thread from list
|
||||
threads.value = threads.value.filter((t) => t.id !== threadId);
|
||||
threadHistory.value.threads = threadHistory.value.threads.filter((t) => t.id !== threadId);
|
||||
disposeRuntime(threadId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function renameThread(threadId: string, title: string): Promise<void> {
|
||||
const thread = threads.value.find((t) => t.id === threadId);
|
||||
if (thread) {
|
||||
thread.title = title;
|
||||
}
|
||||
const entries = localThreadEntries(threadId);
|
||||
const previousTitle = entries[0]?.title;
|
||||
for (const entry of entries) entry.title = title;
|
||||
|
||||
// Only call API for threads that have been persisted to the backend
|
||||
if (persistedThreadIds.has(threadId)) {
|
||||
if (!persistedThreadIds.has(threadId)) return;
|
||||
try {
|
||||
await renameThreadApi(rootStore.restApiContext, threadId, title);
|
||||
} catch (error) {
|
||||
// Roll back the optimistic title so the list does not keep a name the server rejected
|
||||
if (previousTitle !== undefined) {
|
||||
for (const entry of entries) entry.title = previousTitle;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +438,10 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
||||
updateThreadMetadata,
|
||||
setThreadMetadata,
|
||||
loadThreads,
|
||||
loadThread,
|
||||
threadHistory,
|
||||
resetThreadHistory,
|
||||
loadThreadHistoryPage,
|
||||
fetchCredits,
|
||||
handleCreditsPush,
|
||||
getOrCreateRuntime,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
INSTANCE_AI_THREAD_VIEW,
|
||||
INSTANCE_AI_SETTINGS_VIEW,
|
||||
INSTANCE_AI_NEW_VIEW,
|
||||
INSTANCE_AI_THREADS_VIEW,
|
||||
} from './constants';
|
||||
import {
|
||||
ensurePersonalProjectId,
|
||||
@@ -47,6 +48,7 @@ function hasInstanceAiSettingsContent(): boolean {
|
||||
const InstanceAiView = async () => await import('./InstanceAiView.vue');
|
||||
const InstanceAiEmptyView = async () => await import('./InstanceAiEmptyView.vue');
|
||||
const InstanceAiThreadView = async () => await import('./InstanceAiThreadView.vue');
|
||||
const InstanceAiThreadsView = async () => await import('./InstanceAiThreadsView.vue');
|
||||
const SettingsInstanceAiView = async () => await import('./views/SettingsInstanceAiView.vue');
|
||||
|
||||
export const InstanceAiModule = defineFrontendModule({
|
||||
@@ -119,6 +121,11 @@ export const InstanceAiModule = defineFrontendModule({
|
||||
path: '',
|
||||
component: InstanceAiEmptyView,
|
||||
},
|
||||
{
|
||||
name: INSTANCE_AI_THREADS_VIEW,
|
||||
path: 'history',
|
||||
component: InstanceAiThreadsView,
|
||||
},
|
||||
{
|
||||
name: INSTANCE_AI_THREAD_VIEW,
|
||||
path: ':threadId',
|
||||
|
||||
Reference in New Issue
Block a user