mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
feat(core): Support nonlinear conversation model on the FE (no-changelog) (#20842)
This commit is contained in:
parent
c560f05a39
commit
53c45b3036
|
|
@ -127,20 +127,17 @@ export interface ChatHubMessageDto {
|
|||
retryOfMessageId: ChatMessageId | null;
|
||||
revisionOfMessageId: ChatMessageId | null;
|
||||
runIndex: number;
|
||||
|
||||
responseIds: ChatMessageId[];
|
||||
retryIds: ChatMessageId[];
|
||||
revisionIds: ChatMessageId[];
|
||||
}
|
||||
|
||||
export type ChatHubConversationsResponse = ChatHubSessionDto[];
|
||||
|
||||
export interface ChatHubConversationDto {
|
||||
messages: Record<ChatMessageId, ChatHubMessageDto>;
|
||||
rootIds: ChatMessageId[];
|
||||
activeMessageChain: ChatMessageId[];
|
||||
}
|
||||
|
||||
export interface ChatHubConversationResponse {
|
||||
session: ChatHubSessionDto;
|
||||
|
||||
conversation: {
|
||||
messages: Record<string, ChatHubMessageDto>;
|
||||
rootIds: string[];
|
||||
activeMessageChain: string[];
|
||||
};
|
||||
conversation: ChatHubConversationDto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export {
|
|||
type ChatSessionId,
|
||||
type ChatHubMessageDto,
|
||||
type ChatHubSessionDto,
|
||||
type ChatHubConversationDto,
|
||||
type ChatHubConversationResponse,
|
||||
type ChatHubConversationsResponse,
|
||||
} from './chat-hub';
|
||||
|
|
|
|||
|
|
@ -299,9 +299,6 @@ describe('chatHub', () => {
|
|||
expect(messages[msg4.id].content).toBe('message 4a');
|
||||
expect(messages[msg5.id].content).toBe('message 3b');
|
||||
expect(messages[msg6.id].content).toBe('message 4b');
|
||||
expect(messages[msg3.id].revisionIds).toEqual([msg5.id]);
|
||||
expect(messages[msg3.id].responseIds).toEqual([msg4.id]);
|
||||
expect(messages[msg3.id].retryIds).toEqual([]);
|
||||
expect(messages[msg5.id].previousMessageId).toBe(msg2.id);
|
||||
});
|
||||
|
||||
|
|
@ -458,9 +455,6 @@ describe('chatHub', () => {
|
|||
expect(activeMessageChain[1]).toBe(msg2.id);
|
||||
expect(activeMessageChain[2]).toBe(msg3.id);
|
||||
expect(activeMessageChain[3]).toBe(msg5.id);
|
||||
expect(messages[msg4.id].revisionIds).toEqual([]);
|
||||
expect(messages[msg4.id].responseIds).toEqual([]);
|
||||
expect(messages[msg4.id].retryIds).toEqual([msg5.id]);
|
||||
expect(messages[msg5.id].previousMessageId).toBe(msg3.id);
|
||||
expect(messages[msg5.id].retryOfMessageId).toBe(msg4.id);
|
||||
});
|
||||
|
|
@ -523,7 +517,7 @@ describe('chatHub', () => {
|
|||
turnId: ids[2],
|
||||
createdAt: new Date('2025-01-03T00:10:00Z'),
|
||||
});
|
||||
const msg4a = await messagesRepository.createChatMessage({
|
||||
await messagesRepository.createChatMessage({
|
||||
id: ids[3],
|
||||
sessionId: session.id,
|
||||
name: 'ChatGPT',
|
||||
|
|
@ -544,7 +538,7 @@ describe('chatHub', () => {
|
|||
turnId: ids[4],
|
||||
createdAt: new Date('2025-01-03T00:20:00Z'),
|
||||
});
|
||||
const msg4b = await messagesRepository.createChatMessage({
|
||||
await messagesRepository.createChatMessage({
|
||||
id: ids[5],
|
||||
sessionId: session.id,
|
||||
name: 'ChatGPT',
|
||||
|
|
@ -613,22 +607,6 @@ describe('chatHub', () => {
|
|||
expect(activeMessageChain[2]).toBe(msg3d.id);
|
||||
expect(activeMessageChain[3]).toBe(msg4c.id);
|
||||
|
||||
expect(messages[msg1.id].revisionIds).toEqual([msg1b.id]);
|
||||
expect(messages[msg1b.id].responseIds).toEqual([]);
|
||||
expect(messages[msg1b.id].retryIds).toEqual([]);
|
||||
|
||||
expect(messages[msg2.id].revisionIds).toEqual([]);
|
||||
expect(messages[msg2.id].responseIds).toEqual([msg3a.id, msg3b.id]);
|
||||
expect(messages[msg2.id].retryIds).toEqual([msg2r.id]);
|
||||
|
||||
expect(messages[msg3b.id].revisionIds).toEqual([]);
|
||||
expect(messages[msg3b.id].responseIds).toEqual([msg4b.id]);
|
||||
expect(messages[msg3b.id].retryIds).toEqual([]);
|
||||
|
||||
expect(messages[msg4a.id].revisionIds).toEqual([]);
|
||||
expect(messages[msg4a.id].responseIds).toEqual([]);
|
||||
expect(messages[msg4a.id].retryIds).toEqual([]);
|
||||
|
||||
expect(messages[msg2r.id].previousMessageId).toBe(msg1.id);
|
||||
expect(messages[msg2r.id].retryOfMessageId).toBe(msg2.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -867,8 +867,9 @@ export class ChatHubService {
|
|||
}
|
||||
|
||||
const messages = await this.messageRepository.getManyBySessionId(sessionId);
|
||||
const messagesGraph: Record<ChatMessageId, ChatHubMessageDto> =
|
||||
this.buildMessagesGraph(messages);
|
||||
const messagesGraph: Record<ChatMessageId, ChatHubMessageDto> = Object.fromEntries(
|
||||
messages.map((m) => [m.id, this.convertMessageToDto(m)]),
|
||||
);
|
||||
|
||||
const rootIds = messages.filter((r) => r.previousMessageId === null).map((r) => r.id);
|
||||
const activeMessages = messages.filter((m) => m.state === 'active');
|
||||
|
|
@ -897,69 +898,27 @@ export class ChatHubService {
|
|||
};
|
||||
}
|
||||
|
||||
private buildMessagesGraph(messages: ChatHubMessage[]) {
|
||||
const messagesGraph: Record<ChatMessageId, ChatHubMessageDto> = {};
|
||||
private convertMessageToDto(message: ChatHubMessage): ChatHubMessageDto {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
type: message.type,
|
||||
name: message.name,
|
||||
content: message.content,
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
workflowId: message.workflowId,
|
||||
executionId: message.executionId,
|
||||
state: message.state,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
updatedAt: message.updatedAt.toISOString(),
|
||||
|
||||
for (const message of messages) {
|
||||
messagesGraph[message.id] = {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
type: message.type,
|
||||
name: message.name,
|
||||
content: message.content,
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
workflowId: message.workflowId,
|
||||
executionId: message.executionId,
|
||||
state: message.state,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
updatedAt: message.updatedAt.toISOString(),
|
||||
|
||||
previousMessageId: message.previousMessageId,
|
||||
turnId: message.turnId,
|
||||
retryOfMessageId: message.retryOfMessageId,
|
||||
revisionOfMessageId: message.revisionOfMessageId,
|
||||
runIndex: message.runIndex,
|
||||
|
||||
responseIds: [],
|
||||
retryIds: [],
|
||||
revisionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const node of Object.values(messagesGraph)) {
|
||||
if (node.previousMessageId && messagesGraph[node.previousMessageId]) {
|
||||
messagesGraph[node.previousMessageId].responseIds.push(node.id);
|
||||
}
|
||||
if (node.retryOfMessageId && messagesGraph[node.retryOfMessageId]) {
|
||||
messagesGraph[node.retryOfMessageId].retryIds.push(node.id);
|
||||
}
|
||||
if (node.revisionOfMessageId && messagesGraph[node.revisionOfMessageId]) {
|
||||
messagesGraph[node.revisionOfMessageId].revisionIds.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
const sortByRunThenTime = (first: ChatMessageId, second: ChatMessageId) => {
|
||||
const a = messagesGraph[first];
|
||||
const b = messagesGraph[second];
|
||||
|
||||
if (a.runIndex !== b.runIndex) {
|
||||
return a.runIndex - b.runIndex;
|
||||
}
|
||||
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt < b.createdAt ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.id < b.id ? -1 : 1;
|
||||
previousMessageId: message.previousMessageId,
|
||||
turnId: message.turnId,
|
||||
retryOfMessageId: message.retryOfMessageId,
|
||||
revisionOfMessageId: message.revisionOfMessageId,
|
||||
runIndex: message.runIndex,
|
||||
};
|
||||
|
||||
for (const node of Object.values(messagesGraph)) {
|
||||
node.responseIds.sort(sortByRunThenTime);
|
||||
node.retryIds.sort(sortByRunThenTime);
|
||||
node.revisionIds.sort(sortByRunThenTime);
|
||||
}
|
||||
return messagesGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,18 +10,14 @@ import CredentialSelectorModal from './components/CredentialSelectorModal.vue';
|
|||
import { useChatStore } from './chat.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import {
|
||||
type ChatMessage as ChatMessageType,
|
||||
credentialsMapSchema,
|
||||
type CredentialsMap,
|
||||
type Suggestion,
|
||||
} from './chat.types';
|
||||
import { credentialsMapSchema, type CredentialsMap, type Suggestion } from './chat.types';
|
||||
import {
|
||||
chatHubConversationModelSchema,
|
||||
type ChatHubProvider,
|
||||
PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
type ChatHubConversationModel,
|
||||
chatHubProviderSchema,
|
||||
type ChatHubMessageDto,
|
||||
} from '@n8n/api-types';
|
||||
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
|
||||
import {
|
||||
|
|
@ -113,7 +109,7 @@ const mergedCredentials = computed(() => ({
|
|||
...selectedCredentials.value,
|
||||
}));
|
||||
|
||||
const chatMessages = computed(() => chatStore.messagesBySession[sessionId.value] ?? []);
|
||||
const chatMessages = computed(() => chatStore.getActiveMessages(sessionId.value));
|
||||
const isNewChat = computed(() => route.name === CHAT_VIEW);
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (!selectedModel.value) {
|
||||
|
|
@ -192,7 +188,7 @@ watch(
|
|||
async ([id, isNew]) => {
|
||||
didSubmitInCurrentSession.value = false;
|
||||
|
||||
if (!isNew && !chatStore.messagesBySession[id]) {
|
||||
if (!isNew && !chatStore.getConversation(id)) {
|
||||
try {
|
||||
await chatStore.fetchMessages(id);
|
||||
} catch (error) {
|
||||
|
|
@ -277,8 +273,8 @@ function handleCancelEditMessage() {
|
|||
editingMessageId.value = undefined;
|
||||
}
|
||||
|
||||
function handleEditMessage(message: ChatMessageType) {
|
||||
if (chatStore.isResponding || message.type === 'error' || !selectedModel.value) {
|
||||
function handleEditMessage(message: ChatHubMessageDto) {
|
||||
if (chatStore.isResponding || message.type !== 'human' || !selectedModel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +284,7 @@ function handleEditMessage(message: ChatMessageType) {
|
|||
return;
|
||||
}
|
||||
|
||||
chatStore.editMessage(sessionId.value, message.id, message.text, selectedModel.value, {
|
||||
chatStore.editMessage(sessionId.value, message.id, message.content, selectedModel.value, {
|
||||
[PROVIDER_CREDENTIAL_TYPE_MAP[selectedModel.value.provider]]: {
|
||||
id: credentialsId,
|
||||
name: '',
|
||||
|
|
@ -297,8 +293,8 @@ function handleEditMessage(message: ChatMessageType) {
|
|||
editingMessageId.value = undefined;
|
||||
}
|
||||
|
||||
function handleRegenerateMessage(message: ChatMessageType) {
|
||||
if (chatStore.isResponding || message.type === 'error' || !selectedModel.value) {
|
||||
function handleRegenerateMessage(message: ChatHubMessageDto) {
|
||||
if (chatStore.isResponding || message.type !== 'ai' || !selectedModel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,25 +18,157 @@ import type {
|
|||
ChatHubSessionDto,
|
||||
ChatMessageId,
|
||||
ChatSessionId,
|
||||
ChatHubMessageDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { StructuredChunk, ChatMessage, CredentialsMap } from './chat.types';
|
||||
import type { StructuredChunk, CredentialsMap, ChatMessage, ChatConversation } from './chat.types';
|
||||
|
||||
export const useChatStore = defineStore(CHAT_STORE, () => {
|
||||
const rootStore = useRootStore();
|
||||
const models = ref<ChatModelsResponse>();
|
||||
const loadingModels = ref(false);
|
||||
const ongoingStreaming = ref<{ messageId: string; replyToMessageId: string }>();
|
||||
const messagesBySession = ref<Partial<Record<string, ChatMessage[]>>>({});
|
||||
const sessions = ref<ChatHubSessionDto[]>([]);
|
||||
|
||||
const ongoingStreaming = ref<{ messageId: ChatMessageId; replyToMessageId: ChatMessageId }>();
|
||||
const isResponding = computed(() => ongoingStreaming.value !== undefined);
|
||||
|
||||
const getLastMessage = (sessionId: string) => {
|
||||
const msgs = messagesBySession.value[sessionId];
|
||||
if (!msgs || msgs.length === 0) return null;
|
||||
return msgs[msgs.length - 1];
|
||||
const conversationsBySession = ref<Map<ChatSessionId, ChatConversation>>(new Map());
|
||||
const sessions = ref<ChatHubSessionDto[]>([]);
|
||||
|
||||
const getConversation = (sessionId: ChatSessionId): ChatConversation | undefined =>
|
||||
conversationsBySession.value.get(sessionId);
|
||||
|
||||
const getActiveMessages = (sessionId: ChatSessionId): ChatMessage[] => {
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) return [];
|
||||
|
||||
return conversation.activeMessageChain.map((id) => conversation.messages[id]).filter(Boolean);
|
||||
};
|
||||
|
||||
function ensureConversation(sessionId: ChatSessionId): ChatConversation {
|
||||
if (!conversationsBySession.value.has(sessionId)) {
|
||||
conversationsBySession.value.set(sessionId, {
|
||||
messages: {},
|
||||
rootIds: [],
|
||||
activeMessageChain: [],
|
||||
});
|
||||
}
|
||||
|
||||
const conversation = conversationsBySession.value.get(sessionId);
|
||||
if (!conversation) {
|
||||
throw new Error(`Conversation for session ID ${sessionId} not found`);
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
function computeActiveChain(conversation: ChatConversation, lastMessageId: ChatMessageId | null) {
|
||||
const messages = conversation.messages;
|
||||
const chain: ChatMessageId[] = [];
|
||||
|
||||
if (!lastMessageId) {
|
||||
return chain;
|
||||
}
|
||||
const visited = new Set<ChatMessageId>();
|
||||
|
||||
let current: ChatMessageId | null = lastMessageId;
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
chain.unshift(current);
|
||||
visited.add(current);
|
||||
current = messages[current]?.previousMessageId ?? null;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
function computeAlternativesAndResponses(messages: ChatHubMessageDto[]) {
|
||||
const messagesGraph: Record<ChatMessageId, ChatMessage> = {};
|
||||
|
||||
for (const message of messages) {
|
||||
messagesGraph[message.id] = {
|
||||
...message,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const node of Object.values(messagesGraph)) {
|
||||
if (node.previousMessageId && messagesGraph[node.previousMessageId]) {
|
||||
messagesGraph[node.previousMessageId].responses.push(node.id);
|
||||
}
|
||||
if (node.retryOfMessageId && messagesGraph[node.retryOfMessageId]) {
|
||||
messagesGraph[node.retryOfMessageId].alternatives.push(node.id);
|
||||
}
|
||||
if (node.revisionOfMessageId && messagesGraph[node.revisionOfMessageId]) {
|
||||
messagesGraph[node.revisionOfMessageId].alternatives.push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
const sortByRunThenTime = (first: ChatMessageId, second: ChatMessageId) => {
|
||||
const a = messagesGraph[first];
|
||||
const b = messagesGraph[second];
|
||||
|
||||
if (a.runIndex !== b.runIndex) {
|
||||
return a.runIndex - b.runIndex;
|
||||
}
|
||||
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt < b.createdAt ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.id < b.id ? -1 : 1;
|
||||
};
|
||||
|
||||
// Second pass: Add cross-links for alternatives
|
||||
for (const node of Object.values(messagesGraph)) {
|
||||
if (!node.alternatives.includes(node.id)) {
|
||||
node.alternatives.push(node.id);
|
||||
}
|
||||
|
||||
if (node.retryOfMessageId && messagesGraph[node.retryOfMessageId]) {
|
||||
node.alternatives.push(node.retryOfMessageId);
|
||||
for (const other of messagesGraph[node.retryOfMessageId].alternatives) {
|
||||
if (other !== node.id && !node.alternatives.includes(other)) {
|
||||
node.alternatives.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.revisionOfMessageId && messagesGraph[node.revisionOfMessageId]) {
|
||||
node.alternatives.push(node.revisionOfMessageId);
|
||||
for (const other of messagesGraph[node.revisionOfMessageId].alternatives) {
|
||||
if (other !== node.id && !node.alternatives.includes(other)) {
|
||||
node.alternatives.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.responses.sort(sortByRunThenTime);
|
||||
node.alternatives.sort(sortByRunThenTime);
|
||||
}
|
||||
|
||||
return messagesGraph;
|
||||
}
|
||||
|
||||
function addMessage(sessionId: ChatSessionId, message: ChatMessage) {
|
||||
const conversation = ensureConversation(sessionId);
|
||||
|
||||
conversation.messages[message.id] = message;
|
||||
|
||||
// TODO: Recomputing the entire graph shouldn't be needed here, we could just
|
||||
conversation.messages = computeAlternativesAndResponses(Object.values(conversation.messages));
|
||||
|
||||
conversation.activeMessageChain = computeActiveChain(conversation, message.id);
|
||||
}
|
||||
|
||||
function appendMessage(sessionId: ChatSessionId, messageId: ChatMessageId, chunk: string) {
|
||||
const conversation = ensureConversation(sessionId);
|
||||
const message = conversation.messages[messageId];
|
||||
if (!message) {
|
||||
throw new Error(`Message with ID ${messageId} not found in session ${sessionId}`);
|
||||
}
|
||||
|
||||
message.content += chunk;
|
||||
}
|
||||
|
||||
async function fetchChatModels(credentialMap: CredentialsMap) {
|
||||
loadingModels.value = true;
|
||||
models.value = await fetchChatModelsApi(rootStore.restApiContext, {
|
||||
|
|
@ -52,86 +184,55 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
|
||||
async function fetchMessages(sessionId: string) {
|
||||
const { conversation } = await fetchMessagesApi(rootStore.restApiContext, sessionId);
|
||||
const { messages, activeMessageChain } = conversation;
|
||||
|
||||
messagesBySession.value = {
|
||||
...messagesBySession.value,
|
||||
[sessionId]: activeMessageChain.map((id) => ({
|
||||
id: messages[id].id,
|
||||
role: messages[id].type === 'ai' ? 'assistant' : 'user',
|
||||
type: 'message' as const,
|
||||
text: messages[id].content,
|
||||
key: messages[id].id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const messages = computeAlternativesAndResponses(Object.values(conversation.messages));
|
||||
|
||||
function addUserMessage(sessionId: string, content: string, id: string) {
|
||||
messagesBySession.value = {
|
||||
...messagesBySession.value,
|
||||
[sessionId]: [
|
||||
...(messagesBySession.value[sessionId] ?? []),
|
||||
{
|
||||
id,
|
||||
key: id,
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function addAiMessage(sessionId: string, content: string, id: string, key: string) {
|
||||
messagesBySession.value = {
|
||||
...messagesBySession.value,
|
||||
[sessionId]: [
|
||||
...(messagesBySession.value[sessionId] ?? []),
|
||||
{
|
||||
id,
|
||||
key,
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function appendMessage(sessionId: string, content: string, key: string) {
|
||||
messagesBySession.value = {
|
||||
...messagesBySession.value,
|
||||
[sessionId]: (messagesBySession.value[sessionId] ?? []).map((msg) => {
|
||||
if (msg.key === key && msg.type === 'message') {
|
||||
return {
|
||||
...msg,
|
||||
text: msg.text + content,
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
};
|
||||
conversationsBySession.value.set(sessionId, {
|
||||
...conversation,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
function onBeginMessage(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
replyToMessageId: string,
|
||||
nodeId: string,
|
||||
runIndex?: number,
|
||||
retryOfMessageId: string | null,
|
||||
_nodeId: string,
|
||||
_runIndex?: number,
|
||||
) {
|
||||
ongoingStreaming.value = { messageId, replyToMessageId };
|
||||
addAiMessage(sessionId, '', messageId, `${messageId}-${nodeId}-${runIndex ?? 0}`);
|
||||
addMessage(sessionId, {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: 'ai',
|
||||
name: 'AI',
|
||||
content: '',
|
||||
provider: null,
|
||||
model: null,
|
||||
workflowId: null,
|
||||
executionId: null,
|
||||
state: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
previousMessageId: replyToMessageId,
|
||||
turnId: null,
|
||||
retryOfMessageId,
|
||||
revisionOfMessageId: null,
|
||||
runIndex: 0,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
});
|
||||
}
|
||||
|
||||
function onChunk(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
chunk: string,
|
||||
nodeId?: string,
|
||||
runIndex?: number,
|
||||
_nodeId?: string,
|
||||
_runIndex?: number,
|
||||
) {
|
||||
appendMessage(sessionId, chunk, `${messageId}-${nodeId}-${runIndex ?? 0}`);
|
||||
appendMessage(sessionId, messageId, chunk);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
|
@ -144,13 +245,14 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
message: StructuredChunk,
|
||||
messageId: string,
|
||||
replyToMessageId: string,
|
||||
retryOfMessageId: string | null,
|
||||
) {
|
||||
const nodeId = message.metadata?.nodeId || 'unknown';
|
||||
const runIndex = message.metadata?.runIndex;
|
||||
|
||||
switch (message.type) {
|
||||
case 'begin':
|
||||
onBeginMessage(sessionId, messageId, replyToMessageId, nodeId, runIndex);
|
||||
onBeginMessage(sessionId, messageId, replyToMessageId, retryOfMessageId, nodeId, runIndex);
|
||||
break;
|
||||
case 'item':
|
||||
onChunk(sessionId, messageId, message.content ?? '', nodeId, runIndex);
|
||||
|
|
@ -169,8 +271,6 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
onEndMessage(messageId, nodeId, runIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// addAssistantMessages(response.messages);
|
||||
}
|
||||
|
||||
async function onStreamDone() {
|
||||
|
|
@ -191,9 +291,32 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
) {
|
||||
const messageId = uuidv4();
|
||||
const replyId = uuidv4();
|
||||
const previousMessageId = getLastMessage(sessionId)?.id ?? null;
|
||||
const conversation = ensureConversation(sessionId);
|
||||
const previousMessageId = conversation.activeMessageChain.length
|
||||
? conversation.activeMessageChain[conversation.activeMessageChain.length - 1]
|
||||
: null;
|
||||
|
||||
addUserMessage(sessionId, message, messageId);
|
||||
addMessage(sessionId, {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: 'human',
|
||||
name: 'User',
|
||||
content: message,
|
||||
provider: null,
|
||||
model: null,
|
||||
workflowId: null,
|
||||
executionId: null,
|
||||
state: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
previousMessageId,
|
||||
turnId: null,
|
||||
retryOfMessageId: null,
|
||||
revisionOfMessageId: null,
|
||||
runIndex: 0,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
});
|
||||
|
||||
sendMessageApi(
|
||||
rootStore.restApiContext,
|
||||
|
|
@ -206,7 +329,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
credentials,
|
||||
previousMessageId,
|
||||
},
|
||||
(chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, replyId, messageId),
|
||||
(chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, replyId, messageId, null),
|
||||
onStreamDone,
|
||||
onStreamError,
|
||||
);
|
||||
|
|
@ -222,11 +345,30 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
const messageId = uuidv4();
|
||||
const replyId = uuidv4();
|
||||
|
||||
addUserMessage(sessionId, message, messageId);
|
||||
const conversation = ensureConversation(sessionId);
|
||||
const previousMessageId = conversation.messages[editId]?.previousMessageId ?? null;
|
||||
|
||||
// TODO: remove descendants of the message being edited
|
||||
// or better yet, turn the frontend chat into a graph and
|
||||
// maintain the visible active chain, and this would just switch to that branch.
|
||||
addMessage(sessionId, {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: 'human',
|
||||
name: 'User',
|
||||
content: message,
|
||||
provider: null,
|
||||
model: null,
|
||||
workflowId: null,
|
||||
executionId: null,
|
||||
state: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
previousMessageId,
|
||||
turnId: null,
|
||||
retryOfMessageId: null,
|
||||
revisionOfMessageId: editId,
|
||||
runIndex: 0,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
});
|
||||
|
||||
editMessageApi(
|
||||
rootStore.restApiContext,
|
||||
|
|
@ -239,7 +381,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
message,
|
||||
credentials,
|
||||
},
|
||||
(chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, replyId, messageId),
|
||||
(chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, replyId, messageId, null),
|
||||
onStreamDone,
|
||||
onStreamError,
|
||||
);
|
||||
|
|
@ -252,10 +394,12 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
credentials: ChatHubSendMessageRequest['credentials'],
|
||||
) {
|
||||
const replyId = uuidv4();
|
||||
const conversation = ensureConversation(sessionId);
|
||||
const previousMessageId = conversation.messages[retryId]?.previousMessageId ?? null;
|
||||
|
||||
// TODO: remove descendants of the message being retried
|
||||
// or better yet, turn the frontend chat into a graph and
|
||||
// maintain the visible active chain, and this would just switch to that branch.
|
||||
if (!previousMessageId) {
|
||||
throw new Error('No previous message to base regeneration on');
|
||||
}
|
||||
|
||||
regenerateMessageApi(
|
||||
rootStore.restApiContext,
|
||||
|
|
@ -266,7 +410,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
replyId,
|
||||
credentials,
|
||||
},
|
||||
(chunk: StructuredChunk) => onStreamMessage(sessionId, chunk, replyId, retryId),
|
||||
(chunk: StructuredChunk) =>
|
||||
onStreamMessage(sessionId, chunk, replyId, previousMessageId, retryId),
|
||||
onStreamDone,
|
||||
onStreamError,
|
||||
);
|
||||
|
|
@ -290,19 +435,20 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
|
|||
|
||||
return {
|
||||
models,
|
||||
sessions,
|
||||
conversationsBySession,
|
||||
loadingModels,
|
||||
messagesBySession,
|
||||
isResponding,
|
||||
ongoingStreaming,
|
||||
sessions,
|
||||
fetchChatModels,
|
||||
sendMessage,
|
||||
editMessage,
|
||||
regenerateMessage,
|
||||
addUserMessage,
|
||||
fetchSessions,
|
||||
fetchMessages,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
getConversation,
|
||||
getActiveMessages,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { chatHubProviderSchema, type ChatHubSessionDto } from '@n8n/api-types';
|
||||
import {
|
||||
chatHubProviderSchema,
|
||||
type ChatHubMessageDto,
|
||||
type ChatMessageId,
|
||||
type ChatHubSessionDto,
|
||||
type ChatHubConversationDto,
|
||||
} from '@n8n/api-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface UserMessage {
|
||||
|
|
@ -26,7 +32,15 @@ export interface ErrorMessage {
|
|||
}
|
||||
|
||||
export type StreamChunk = AssistantMessage | ErrorMessage;
|
||||
export type ChatMessage = UserMessage | AssistantMessage | ErrorMessage;
|
||||
|
||||
export interface ChatMessage extends ChatHubMessageDto {
|
||||
responses: ChatMessageId[];
|
||||
alternatives: ChatMessageId[];
|
||||
}
|
||||
|
||||
export interface ChatConversation extends ChatHubConversationDto {
|
||||
messages: Record<ChatMessageId, ChatMessage>;
|
||||
}
|
||||
|
||||
export interface StreamOutput {
|
||||
messages: StreamChunk[];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import type { ChatMessage } from '@/features/chatHub/chat.types';
|
||||
import { N8nIcon, N8nInput, N8nButton } from '@n8n/design-system';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
|
|
@ -7,12 +6,12 @@ import markdownLink from 'markdown-it-link-attributes';
|
|||
import type MarkdownIt from 'markdown-it';
|
||||
import ChatMessageActions from './ChatMessageActions.vue';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { ref, nextTick, watch } from 'vue';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { ref, nextTick, watch, useTemplateRef } from 'vue';
|
||||
import ChatTypingIndicator from '@/features/chatHub/components/ChatTypingIndicator.vue';
|
||||
import type { ChatHubMessageDto } from '@n8n/api-types';
|
||||
|
||||
const { message, compact, isEditing, isStreaming } = defineProps<{
|
||||
message: ChatMessage;
|
||||
message: ChatHubMessageDto;
|
||||
compact: boolean;
|
||||
isEditing: boolean;
|
||||
isStreaming: boolean;
|
||||
|
|
@ -21,8 +20,8 @@ const { message, compact, isEditing, isStreaming } = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
startEdit: [];
|
||||
cancelEdit: [];
|
||||
update: [message: ChatMessage];
|
||||
regenerate: [message: ChatMessage];
|
||||
update: [message: ChatHubMessageDto];
|
||||
regenerate: [message: ChatHubMessageDto];
|
||||
}>();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
|
|
@ -32,7 +31,7 @@ const textareaRef = useTemplateRef('textarea');
|
|||
const justCopied = ref(false);
|
||||
|
||||
async function handleCopy() {
|
||||
const text = messageText(message);
|
||||
const text = message.content;
|
||||
await clipboard.copy(text);
|
||||
justCopied.value = true;
|
||||
setTimeout(() => {
|
||||
|
|
@ -49,21 +48,17 @@ function handleCancelEdit() {
|
|||
}
|
||||
|
||||
function handleConfirmEdit() {
|
||||
if (message.type === 'error' || !editedText.value.trim()) {
|
||||
if (message.type === 'ai' || !editedText.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update', { ...message, text: editedText.value });
|
||||
emit('update', { ...message, content: editedText.value });
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
emit('regenerate', message);
|
||||
}
|
||||
|
||||
function messageText(msg: ChatMessage) {
|
||||
return msg.type === 'message' ? msg.text : `**Error:** ${msg.content}`;
|
||||
}
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
|
|
@ -90,7 +85,7 @@ watch(
|
|||
() => isEditing,
|
||||
async (editing) => {
|
||||
if (editing) {
|
||||
editedText.value = messageText(message);
|
||||
editedText.value = message.content;
|
||||
await nextTick();
|
||||
textareaRef.value?.focus();
|
||||
} else {
|
||||
|
|
@ -105,7 +100,7 @@ watch(
|
|||
<div
|
||||
:class="[
|
||||
$style.message,
|
||||
message.role === 'user' ? $style.user : $style.assistant,
|
||||
message.type === 'human' ? $style.user : $style.assistant,
|
||||
{
|
||||
[$style.compact]: compact,
|
||||
},
|
||||
|
|
@ -113,7 +108,7 @@ watch(
|
|||
:data-message-id="message.id"
|
||||
>
|
||||
<div :class="$style.avatar">
|
||||
<N8nIcon :icon="message.role === 'user' ? 'user' : 'sparkles'" width="20" height="20" />
|
||||
<N8nIcon :icon="message.type === 'human' ? 'user' : 'sparkles'" width="20" height="20" />
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<div v-if="isEditing" :class="$style.editContainer">
|
||||
|
|
@ -140,7 +135,7 @@ watch(
|
|||
<div :class="$style.chatMessage">
|
||||
<VueMarkdown
|
||||
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
|
||||
:source="messageText(message)"
|
||||
:source="message.content"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
|
|
@ -148,7 +143,7 @@ watch(
|
|||
<ChatTypingIndicator v-if="isStreaming" :class="$style.typingIndicator" />
|
||||
<ChatMessageActions
|
||||
v-else
|
||||
:role="message.role"
|
||||
:type="message.type"
|
||||
:just-copied="justCopied"
|
||||
:class="$style.actions"
|
||||
@copy="handleCopy"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import type { ChatHubMessageType } from '@n8n/api-types';
|
||||
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const { role, justCopied } = defineProps<{
|
||||
role: 'user' | 'assistant';
|
||||
const { type, justCopied } = defineProps<{
|
||||
type: ChatHubMessageType;
|
||||
justCopied: boolean;
|
||||
}>();
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ function handleRegenerate() {
|
|||
<N8nIconButton icon="pen" type="tertiary" size="medium" text @click="handleEdit" />
|
||||
<template #content>Edit</template>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip v-if="role === 'assistant'" placement="bottom" :show-after="300">
|
||||
<N8nTooltip v-if="type === 'ai'" placement="bottom" :show-after="300">
|
||||
<N8nIconButton
|
||||
icon="refresh-cw"
|
||||
type="tertiary"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user