feat(core): Support nonlinear conversation model on the FE (no-changelog) (#20842)

This commit is contained in:
Jaakko Husso 2025-10-16 13:00:17 +03:00 committed by GitHub
parent c560f05a39
commit 53c45b3036
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 310 additions and 223 deletions

View File

@ -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;
}

View File

@ -24,6 +24,7 @@ export {
type ChatSessionId,
type ChatHubMessageDto,
type ChatHubSessionDto,
type ChatHubConversationDto,
type ChatHubConversationResponse,
type ChatHubConversationsResponse,
} from './chat-hub';

View File

@ -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);
});

View File

@ -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;
}
/**

View File

@ -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;
}

View File

@ -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,
};
});

View File

@ -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[];

View File

@ -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"

View File

@ -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"