feat(Chat Trigger Node): Add support for file uploads & harmonize public and development chat (#9802)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -41,3 +41,15 @@ export const Windowed: Story = {
|
||||
mode: 'window',
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
||||
|
||||
export const WorkflowChat: Story = {
|
||||
name: 'Workflow Chat',
|
||||
args: {
|
||||
webhookUrl: 'http://localhost:5678/webhook/ad324b56-3e40-4b27-874f-58d150504edc/chat',
|
||||
mode: 'fullscreen',
|
||||
allowedFilesMimeTypes: 'image/*,text/*,audio/*, application/pdf',
|
||||
allowFileUploads: true,
|
||||
showWelcomeScreen: false,
|
||||
initialMessages: [],
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
||||
|
||||
@@ -5,15 +5,23 @@ async function getAccessToken() {
|
||||
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const body = args[1]?.body;
|
||||
const headers: RequestInit['headers'] & { 'Content-Type'?: string } = {
|
||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||
...args[1]?.headers,
|
||||
};
|
||||
|
||||
// Automatically set content type to application/json if body is FormData
|
||||
if (body instanceof FormData) {
|
||||
delete headers['Content-Type'];
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const response = await fetch(args[0], {
|
||||
...args[1],
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
||||
...args[1]?.headers,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
return (await response.json()) as T;
|
||||
@@ -37,6 +45,28 @@ export async function post<T>(url: string, body: object = {}, options: RequestIn
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
export async function postWithFiles<T>(
|
||||
url: string,
|
||||
body: Record<string, unknown> = {},
|
||||
files: File[] = [],
|
||||
options: RequestInit = {},
|
||||
) {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key in body) {
|
||||
formData.append(key, body[key] as string);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
return await authenticatedFetch<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||
return await authenticatedFetch<T>(url, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post } from '@n8n/chat/api/generic';
|
||||
import { get, post, postWithFiles } from '@n8n/chat/api/generic';
|
||||
import type {
|
||||
ChatOptions,
|
||||
LoadPreviousSessionResponse,
|
||||
@@ -20,7 +20,27 @@ export async function loadPreviousSession(sessionId: string, options: ChatOption
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendMessage(message: string, sessionId: string, options: ChatOptions) {
|
||||
export async function sendMessage(
|
||||
message: string,
|
||||
files: File[],
|
||||
sessionId: string,
|
||||
options: ChatOptions,
|
||||
) {
|
||||
if (files.length > 0) {
|
||||
return await postWithFiles<SendMessageResponse>(
|
||||
`${options.webhookUrl}`,
|
||||
{
|
||||
action: 'sendMessage',
|
||||
[options.chatSessionKey as string]: sessionId,
|
||||
[options.chatInputKey as string]: message,
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
},
|
||||
files,
|
||||
{
|
||||
headers: options.webhookConfig?.headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
const method = options.webhookConfig?.method === 'POST' ? post : get;
|
||||
return await method<SendMessageResponse>(
|
||||
`${options.webhookUrl}`,
|
||||
|
||||
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal file
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import IconFileText from 'virtual:icons/mdi/fileText';
|
||||
import IconFileMusic from 'virtual:icons/mdi/fileMusic';
|
||||
import IconFileImage from 'virtual:icons/mdi/fileImage';
|
||||
import IconFileVideo from 'virtual:icons/mdi/fileVideo';
|
||||
import IconDelete from 'virtual:icons/mdi/closeThick';
|
||||
import IconPreview from 'virtual:icons/mdi/openInNew';
|
||||
|
||||
import { computed, type FunctionalComponent } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: File;
|
||||
isRemovable: boolean;
|
||||
isPreviewable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [value: File];
|
||||
}>();
|
||||
|
||||
const iconMapper: Record<string, FunctionalComponent> = {
|
||||
document: IconFileText,
|
||||
audio: IconFileMusic,
|
||||
image: IconFileImage,
|
||||
video: IconFileVideo,
|
||||
};
|
||||
|
||||
const TypeIcon = computed(() => {
|
||||
const type = props.file?.type.split('/')[0];
|
||||
return iconMapper[type] || IconFileText;
|
||||
});
|
||||
|
||||
function onClick() {
|
||||
if (props.isRemovable) {
|
||||
emit('remove', props.file);
|
||||
}
|
||||
|
||||
if (props.isPreviewable) {
|
||||
window.open(URL.createObjectURL(props.file));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-file" @click="onClick">
|
||||
<TypeIcon />
|
||||
<p class="chat-file-name">{{ file.name }}</p>
|
||||
<IconDelete v-if="isRemovable" class="chat-file-delete" />
|
||||
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: fit-content;
|
||||
max-width: 15rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
background: white;
|
||||
color: var(--chat--color-dark);
|
||||
border: 1px solid var(--chat--color-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-file-name-tooltip {
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-file-name {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-file-delete,
|
||||
.chat-file-preview {
|
||||
background: none;
|
||||
border: none;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
.chat-file:hover & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,31 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import IconSend from 'virtual:icons/mdi/send';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import IconFilePlus from 'virtual:icons/mdi/filePlus';
|
||||
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import ChatFile from './ChatFile.vue';
|
||||
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
export interface ArrowKeyDownPayload {
|
||||
key: 'ArrowUp' | 'ArrowDown';
|
||||
currentInputValue: string;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
arrowKeyDown: [value: ArrowKeyDownPayload];
|
||||
}>();
|
||||
|
||||
const { options } = useOptions();
|
||||
const chatStore = useChat();
|
||||
const { waitingForResponse } = chatStore;
|
||||
const { t } = useI18n();
|
||||
|
||||
const files = ref<FileList | null>(null);
|
||||
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
||||
const input = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
||||
});
|
||||
|
||||
const isInputDisabled = computed(() => options.disabled?.value === true);
|
||||
const isFileUploadDisabled = computed(
|
||||
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value,
|
||||
);
|
||||
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
|
||||
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
|
||||
|
||||
const styleVars = computed(() => {
|
||||
const controlsCount = isFileUploadAllowed.value ? 2 : 1;
|
||||
return {
|
||||
'--controls-count': controlsCount,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
open: openFileDialog,
|
||||
reset: resetFileDialog,
|
||||
onChange,
|
||||
} = useFileDialog({
|
||||
multiple: true,
|
||||
reset: false,
|
||||
});
|
||||
|
||||
onChange((newFiles) => {
|
||||
if (!newFiles) return;
|
||||
const newFilesDT = new DataTransfer();
|
||||
// Add current files
|
||||
if (files.value) {
|
||||
for (let i = 0; i < files.value.length; i++) {
|
||||
newFilesDT.items.add(files.value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < newFiles.length; i++) {
|
||||
newFilesDT.items.add(newFiles[i]);
|
||||
}
|
||||
files.value = newFilesDT.files;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
chatEventBus.on('focusInput', () => {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.focus();
|
||||
}
|
||||
});
|
||||
chatEventBus.on('focusInput', focusChatInput);
|
||||
chatEventBus.on('blurInput', blurChatInput);
|
||||
chatEventBus.on('setInputValue', setInputValue);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chatEventBus.off('focusInput', focusChatInput);
|
||||
chatEventBus.off('blurInput', blurChatInput);
|
||||
chatEventBus.off('setInputValue', setInputValue);
|
||||
});
|
||||
|
||||
function blurChatInput() {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function focusChatInput() {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setInputValue(value: string) {
|
||||
input.value = value;
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -35,7 +106,11 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
|
||||
const messageText = input.value;
|
||||
input.value = '';
|
||||
await chatStore.sendMessage(messageText);
|
||||
isSubmitting.value = true;
|
||||
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
|
||||
isSubmitting.value = false;
|
||||
resetFileDialog();
|
||||
files.value = null;
|
||||
}
|
||||
|
||||
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
@@ -45,64 +120,156 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
|
||||
await onSubmit(event);
|
||||
}
|
||||
|
||||
function onFileRemove(file: File) {
|
||||
if (!files.value) return;
|
||||
|
||||
const dt = new DataTransfer();
|
||||
for (let i = 0; i < files.value.length; i++) {
|
||||
const currentFile = files.value[i];
|
||||
if (file.name !== currentFile.name) dt.items.add(currentFile);
|
||||
}
|
||||
|
||||
resetFileDialog();
|
||||
files.value = dt.files;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
emit('arrowKeyDown', {
|
||||
key: event.key,
|
||||
currentInputValue: input.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenFileDialog() {
|
||||
if (isFileUploadDisabled.value) return;
|
||||
openFileDialog({ accept: unref(allowedFileTypes) });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="chatTextArea"
|
||||
v-model="input"
|
||||
rows="1"
|
||||
:disabled="isInputDisabled"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
@keydown.enter="onSubmitKeydown"
|
||||
/>
|
||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||
<IconSend height="32" width="32" />
|
||||
</button>
|
||||
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
|
||||
<div class="chat-inputs">
|
||||
<textarea
|
||||
ref="chatTextArea"
|
||||
v-model="input"
|
||||
:disabled="isInputDisabled"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
@keydown.enter="onSubmitKeydown"
|
||||
/>
|
||||
|
||||
<div class="chat-inputs-controls">
|
||||
<button
|
||||
v-if="isFileUploadAllowed"
|
||||
:disabled="isFileUploadDisabled"
|
||||
class="chat-input-send-button"
|
||||
@click="onOpenFileDialog"
|
||||
>
|
||||
<IconFilePlus height="24" width="24" />
|
||||
</button>
|
||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||
<IconSend height="24" width="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="files?.length && !isSubmitting" class="chat-files">
|
||||
<ChatFile
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
:file="file"
|
||||
:is-removable="true"
|
||||
@remove="onFileRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.chat-input {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
.chat-inputs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: var(--chat--input--font-size, inherit);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: var(--chat--spacing);
|
||||
max-height: var(--chat--textarea--height);
|
||||
resize: none;
|
||||
}
|
||||
border: var(--chat--input--border, 0);
|
||||
border-radius: var(--chat--input--border-radius, 0);
|
||||
padding: 0.8rem;
|
||||
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
||||
min-height: var(--chat--textarea--height);
|
||||
max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
|
||||
height: 100%;
|
||||
background: var(--chat--input--background, white);
|
||||
resize: var(--chat--textarea--resize, none);
|
||||
color: var(--chat--input--text-color, initial);
|
||||
outline: none;
|
||||
|
||||
.chat-input-send-button {
|
||||
height: var(--chat--textarea--height);
|
||||
width: var(--chat--textarea--height);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--chat--color-secondary-shade-50);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: var(--chat--color-disabled);
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--chat--input--border-active, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-inputs-controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
}
|
||||
.chat-input-send-button {
|
||||
height: var(--chat--textarea--height);
|
||||
width: var(--chat--textarea--height);
|
||||
background: var(--chat--input--send--button--background, white);
|
||||
cursor: pointer;
|
||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(
|
||||
--chat--input--send--button--background-hover,
|
||||
var(--chat--input--send--button--background)
|
||||
);
|
||||
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: no-drop;
|
||||
color: var(--chat--color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-files {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: var(--chat--files-spacing, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { computed, ref, toRefs, onMounted } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import ChatFile from './ChatFile.vue';
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { useOptions } from '@n8n/chat/composables';
|
||||
|
||||
@@ -12,8 +18,21 @@ const props = defineProps<{
|
||||
message: ChatMessage;
|
||||
}>();
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
|
||||
defineSlots<{
|
||||
beforeMessage(props: { message: ChatMessage }): ChatMessage;
|
||||
default: { message: ChatMessage };
|
||||
}>();
|
||||
|
||||
const { message } = toRefs(props);
|
||||
const { options } = useOptions();
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
const fileSources = ref<Record<string, string>>({});
|
||||
|
||||
const messageText = computed(() => {
|
||||
return (message.value as ChatMessageText).text || '<Empty response>';
|
||||
@@ -36,6 +55,14 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToView = () => {
|
||||
if (messageContainer.value?.scrollIntoView) {
|
||||
messageContainer.value.scrollIntoView({
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
@@ -48,10 +75,37 @@ const markdownOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const messageComponents = options?.messageComponents ?? {};
|
||||
const messageComponents = { ...(options?.messageComponents ?? {}) };
|
||||
|
||||
defineExpose({ scrollToView });
|
||||
|
||||
const readFileAsDataURL = async (file: File): Promise<string> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (message.value.files) {
|
||||
for (const file of message.value.files) {
|
||||
try {
|
||||
const dataURL = await readFileAsDataURL(file);
|
||||
fileSources.value[file.name] = dataURL;
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message" :class="classes">
|
||||
<div ref="messageContainer" class="chat-message" :class="classes">
|
||||
<div v-if="$slots.beforeMessage" class="chat-message-actions">
|
||||
<slot name="beforeMessage" v-bind="{ message }" />
|
||||
</div>
|
||||
<slot>
|
||||
<template v-if="message.type === 'component' && messageComponents[message.key]">
|
||||
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
|
||||
@@ -63,6 +117,11 @@ const messageComponents = options?.messageComponents ?? {};
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
<div v-if="(message.files ?? []).length > 0" class="chat-message-files">
|
||||
<div v-for="file in message.files ?? []" :key="file.name" class="chat-message-file">
|
||||
<ChatFile :file="file" :is-removable="false" :is-previewable="true" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -70,11 +129,33 @@ const messageComponents = options?.messageComponents ?? {};
|
||||
<style lang="scss">
|
||||
.chat-message {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: 80%;
|
||||
font-size: var(--chat--message--font-size, 1rem);
|
||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||
|
||||
.chat-message-actions {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&.chat-message-from-user .chat-message-actions {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: var(--chat--message-line-height, 1.8);
|
||||
word-wrap: break-word;
|
||||
@@ -82,7 +163,7 @@ const messageComponents = options?.messageComponents ?? {};
|
||||
|
||||
// Default message gap is half of the spacing
|
||||
+ .chat-message {
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 0.5));
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
|
||||
}
|
||||
|
||||
// Spacing between messages from different senders is double the individual message gap
|
||||
@@ -133,5 +214,11 @@ const messageComponents = options?.messageComponents ?? {};
|
||||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
}
|
||||
.chat-message-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Message } from './index';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
|
||||
@@ -18,7 +18,7 @@ const message: ChatMessage = {
|
||||
sender: 'bot',
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
const messageContainer = ref<InstanceType<typeof Message>>();
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -26,9 +26,13 @@ const classes = computed(() => {
|
||||
[`chat-message-typing-animation-${props.animation}`]: true,
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
messageContainer.value?.scrollToView();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Message :class="classes" :message="message">
|
||||
<Message ref="messageContainer" :class="classes" :message="message">
|
||||
<div class="chat-message-typing-body">
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import Message from '@n8n/chat/components/Message.vue';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
|
||||
@@ -8,9 +9,23 @@ defineProps<{
|
||||
messages: ChatMessage[];
|
||||
}>();
|
||||
|
||||
const chatStore = useChat();
|
||||
defineSlots<{
|
||||
beforeMessage(props: { message: ChatMessage }): ChatMessage;
|
||||
}>();
|
||||
|
||||
const chatStore = useChat();
|
||||
const messageComponents = ref<Array<InstanceType<typeof Message>>>([]);
|
||||
const { initialMessages, waitingForResponse } = chatStore;
|
||||
|
||||
watch(
|
||||
() => messageComponents.value.length,
|
||||
() => {
|
||||
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
|
||||
if (lastMessageComponent) {
|
||||
lastMessageComponent.scrollToView();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-messages-list">
|
||||
@@ -19,7 +34,14 @@ const { initialMessages, waitingForResponse } = chatStore;
|
||||
:key="initialMessage.id"
|
||||
:message="initialMessage"
|
||||
/>
|
||||
<Message v-for="message in messages" :key="message.id" :message="message" />
|
||||
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<Message ref="messageComponents" :message="message">
|
||||
<template #beforeMessage="{ message }">
|
||||
<slot name="beforeMessage" v-bind="{ message }" />
|
||||
</template>
|
||||
</Message>
|
||||
</template>
|
||||
<MessageTyping v-if="waitingForResponse" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
@import 'tokens';
|
||||
@import 'markdown';
|
||||
|
||||
627
packages/@n8n/chat/src/css/markdown.scss
Normal file
627
packages/@n8n/chat/src/css/markdown.scss
Normal file
@@ -0,0 +1,627 @@
|
||||
@import 'highlight.js/styles/github.css';
|
||||
|
||||
// https://github.com/pxlrbt/markdown-css
|
||||
.chat-message-markdown {
|
||||
/*
|
||||
universalize.css (v1.0.2) — by Alexander Sandberg (https://alexandersandberg.com)
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
Based on Sanitize.css (https://github.com/csstools/sanitize.css).
|
||||
|
||||
(all) = Used for all browsers.
|
||||
x lines = Applies to x lines down, including current line.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/*
|
||||
1. Use default UI font (all)
|
||||
2. Make font size more accessible to everyone (all)
|
||||
3. Make line height consistent (all)
|
||||
4. Prevent font size adjustment after orientation changes (IE, iOS)
|
||||
5. Prevent overflow from long words (all)
|
||||
*/
|
||||
font-size: 125%; /* 2 */
|
||||
line-height: 1.6; /* 3 */
|
||||
-webkit-text-size-adjust: 100%; /* 4 */
|
||||
word-break: break-word; /* 5 */
|
||||
|
||||
/*
|
||||
Prevent padding and border from affecting width (all)
|
||||
*/
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Inherit text decoration (all)
|
||||
2. Inherit vertical alignment (all)
|
||||
*/
|
||||
::before,
|
||||
::after {
|
||||
text-decoration: inherit; /* 1 */
|
||||
vertical-align: inherit; /* 2 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
Remove inconsistent and unnecessary margins
|
||||
*/
|
||||
body, /* (all) */
|
||||
dl dl, /* (Chrome, Edge, IE, Safari) 5 lines */
|
||||
dl ol,
|
||||
dl ul,
|
||||
ol dl,
|
||||
ul dl,
|
||||
ol ol, /* (Edge 18-, IE) 4 lines */
|
||||
ol ul,
|
||||
ul ol,
|
||||
ul ul,
|
||||
button, /* (Safari) 3 lines */
|
||||
input,
|
||||
select,
|
||||
textarea { /* (Firefox, Safari) */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Show overflow (IE18-, IE)
|
||||
2. Correct sizing (Firefox)
|
||||
*/
|
||||
hr {
|
||||
overflow: visible;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Add correct display
|
||||
*/
|
||||
main, /* (IE11) */
|
||||
details { /* (Edge 18-, IE) */
|
||||
display: block;
|
||||
}
|
||||
|
||||
summary { /* (all) */
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove style on navigation lists (all)
|
||||
*/
|
||||
nav ol,
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use default monospace UI font (all)
|
||||
2. Correct font sizing (all)
|
||||
*/
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family:
|
||||
/* macOS 10.10+ */ "Menlo",
|
||||
/* Windows 6+ */ "Consolas",
|
||||
/* Android 4+ */ "Roboto Mono",
|
||||
/* Ubuntu 10.10+ */ "Ubuntu Monospace",
|
||||
/* KDE Plasma 5+ */ "Noto Mono",
|
||||
/* KDE Plasma 4+ */ "Oxygen Mono",
|
||||
/* Linux/OpenOffice fallback */ "Liberation Mono",
|
||||
/* fallback */ monospace,
|
||||
/* macOS emoji */ "Apple Color Emoji",
|
||||
/* Windows emoji */ "Segoe UI Emoji",
|
||||
/* Windows emoji */ "Segoe UI Symbol",
|
||||
/* Linux emoji */ "Noto Color Emoji"; /* 1 */
|
||||
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change cursor for <abbr> elements (all)
|
||||
2. Add correct text decoration (Edge 18-, IE, Safari)
|
||||
*/
|
||||
abbr[title] {
|
||||
cursor: help; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add correct font weight (Chrome, Edge, Safari)
|
||||
*/
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
Add correct font size (all)
|
||||
*/
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Change alignment on media elements (all)
|
||||
*/
|
||||
audio,
|
||||
canvas,
|
||||
iframe,
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove border on iframes (all)
|
||||
*/
|
||||
iframe {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Change fill color to match text (all)
|
||||
*/
|
||||
svg:not([fill]) {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/*
|
||||
Hide overflow (IE11)
|
||||
*/
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
Show overflow (Edge 18-, IE)
|
||||
*/
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove inheritance of text transform (Edge 18-, Firefox, IE)
|
||||
*/
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct inability to style buttons (iOS, Safari)
|
||||
*/
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Fix inconsistent appearance (all)
|
||||
2. Correct padding (Firefox)
|
||||
*/
|
||||
fieldset {
|
||||
border: 1px solid #666; /* 1 */
|
||||
padding: 0.35em 0.75em 0.625em; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct color inheritance from <fieldset> (IE)
|
||||
2. Correct text wrapping (Edge 18-, IE)
|
||||
*/
|
||||
legend {
|
||||
color: inherit; /* 1 */
|
||||
display: table; /* 2 */
|
||||
max-width: 100%; /* 2 */
|
||||
white-space: normal; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add correct display (Edge 18-, IE)
|
||||
2. Add correct vertical alignment (Chrome, Edge, Firefox)
|
||||
*/
|
||||
progress {
|
||||
display: inline-block; /* 1 */
|
||||
vertical-align: baseline; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove default vertical scrollbar (IE)
|
||||
2. Change resize direction (all)
|
||||
*/
|
||||
textarea {
|
||||
overflow: auto; /* 1 */
|
||||
resize: vertical; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct outline style (Safari)
|
||||
2. Correct odd appearance (Chrome, Edge, Safari)
|
||||
*/
|
||||
[type="search"] {
|
||||
outline-offset: -2px; /* 1 */
|
||||
-webkit-appearance: textfield; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Correct cursor style of increment and decrement buttons (Safari)
|
||||
*/
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct text style (Chrome, Edge, Safari)
|
||||
*/
|
||||
::-webkit-input-placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.54;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove inner padding (Chrome, Edge, Safari on macOS)
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Inherit font properties (Safari)
|
||||
2. Correct inability to style upload buttons (iOS, Safari)
|
||||
*/
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit; /* 1 */
|
||||
-webkit-appearance: button; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove inner border and padding of focus outlines (Firefox)
|
||||
*/
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Restore focus outline style (Firefox)
|
||||
*/
|
||||
:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove :invalid styles (Firefox)
|
||||
*/
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Change cursor on busy elements (all)
|
||||
*/
|
||||
[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/*
|
||||
Change cursor on control elements (all)
|
||||
*/
|
||||
[aria-controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Change cursor on disabled, non-editable, or inoperable elements (all)
|
||||
*/
|
||||
[aria-disabled="true"],
|
||||
[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/*
|
||||
Change display on visually hidden accessible elements (all)
|
||||
*/
|
||||
[aria-hidden="false"][hidden] {
|
||||
display: inline;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
[aria-hidden="false"][hidden]:not(:focus) {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/*
|
||||
Print out URLs after links (all)
|
||||
*/
|
||||
@media print {
|
||||
a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
}
|
||||
}
|
||||
/* ----- Variables ----- */
|
||||
|
||||
/* Light mode default, dark mode if recognized as preferred */
|
||||
:root {
|
||||
--background-main: #fefefe;
|
||||
--background-element: #eee;
|
||||
--background-inverted: #282a36;
|
||||
--text-main: #1f1f1f;
|
||||
--text-alt: #333;
|
||||
--text-inverted: #fefefe;
|
||||
--border-element: #282a36;
|
||||
--theme: #7a283a;
|
||||
--theme-light: hsl(0, 25%, 65%);
|
||||
--theme-dark: hsl(0, 25%, 45%);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-main: #282a36;
|
||||
--text-main: #fefefe;
|
||||
}
|
||||
} */
|
||||
/* ----- Base ----- */
|
||||
|
||||
body {
|
||||
margin: auto;
|
||||
max-width: 36rem;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* ----- Typography ----- */
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 3.2rem 0 0.8em;
|
||||
}
|
||||
|
||||
/*
|
||||
Heading sizes based on a modular scale of 1.25 (all)
|
||||
*/
|
||||
h1 {
|
||||
font-size: 2.441rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.953rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.563rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
|
||||
/* differentiate from h5, somehow. color or style? */
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
figure {
|
||||
margin: 0.6rem 0 1.2rem;
|
||||
}
|
||||
|
||||
/*
|
||||
Subtitles
|
||||
- Change to header h* + span instead?
|
||||
- Add support for taglines (small title above main) as well? Needs <header>:
|
||||
header > span:first-child
|
||||
*/
|
||||
h1 span,
|
||||
h2 span,
|
||||
h3 span,
|
||||
h4 span,
|
||||
h5 span,
|
||||
h6 span {
|
||||
display: block;
|
||||
font-size: 1em;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
line-height: 1.3;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
h2 span {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
h3 span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
h4 span {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 1em;
|
||||
opacity: 0.8; /* or some other way of differentiating it from body text */
|
||||
}
|
||||
|
||||
mark {
|
||||
background: pink; /* change to proper color, based on theme */
|
||||
}
|
||||
|
||||
/*
|
||||
Define a custom tab-size in browsers that support it.
|
||||
*/
|
||||
pre {
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/*
|
||||
Long underlined text can be hard to read for dyslexics. Replace with bold.
|
||||
*/
|
||||
ins {
|
||||
text-decoration: none;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 0.3rem solid #7a283a;
|
||||
border-left: 0.3rem solid var(--theme);
|
||||
margin: 0.6rem 0 1.2rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 1.2em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
/* ----- Layout ----- */
|
||||
|
||||
body {
|
||||
background: #fefefe;
|
||||
background: var(--background-main);
|
||||
color: #1f1f1f;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #7a283a;
|
||||
color: var(--theme);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: hsl(0, 25%, 65%);
|
||||
color: var(--theme-light);
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: hsl(0, 25%, 45%);
|
||||
color: var(--theme-dark);
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: 3px solid hsl(0, 25%, 65%);
|
||||
outline: 3px solid var(--theme-light);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #eee;
|
||||
background: var(--background-element);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #282a36;
|
||||
border: 2px solid var(--border-element);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: pink; /* change to proper color, based on theme */
|
||||
padding: 0.1em 0.15em;
|
||||
}
|
||||
|
||||
kbd, /* different style for kbd? */
|
||||
code {
|
||||
background: #eee;
|
||||
padding: 0.1em 0.25em;
|
||||
border-radius: 0.2rem;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
|
||||
kbd > kbd {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
padding: 0.3em 0.7em;
|
||||
word-break: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* ----- Forms ----- */
|
||||
/* ----- Misc ----- */
|
||||
|
||||
[tabindex="-1"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[aria-disabled],
|
||||
[disabled] {
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Style anchor links only
|
||||
*/
|
||||
a[href^='#']::after {
|
||||
content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
Skip link
|
||||
*/
|
||||
body > a:first-child {
|
||||
background: #7a283a;
|
||||
background: var(--theme);
|
||||
border-radius: 0.2rem;
|
||||
color: #fefefe;
|
||||
color: var(--text-inverted);
|
||||
padding: 0.3em 0.5em;
|
||||
position: absolute;
|
||||
top: -10rem;
|
||||
}
|
||||
|
||||
body > a:first-child:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,12 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
})),
|
||||
);
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
async function sendMessage(text: string, files: File[] = []) {
|
||||
const sentMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'user',
|
||||
files,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -41,6 +42,7 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||
|
||||
const sendMessageResponse = await api.sendMessage(
|
||||
text,
|
||||
files,
|
||||
currentSessionId.value as string,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -8,5 +8,5 @@ export interface Chat {
|
||||
waitingForResponse: Ref<boolean>;
|
||||
loadPreviousSession?: () => Promise<string | undefined>;
|
||||
startNewSession?: () => Promise<void>;
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
sendMessage: (text: string, files: File[]) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ interface ChatMessageBase {
|
||||
createdAt: string;
|
||||
transparent?: boolean;
|
||||
sender: 'user' | 'bot';
|
||||
files?: File[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
export interface ChatOptions {
|
||||
webhookUrl: string;
|
||||
webhookConfig?: {
|
||||
@@ -30,4 +31,6 @@ export interface ChatOptions {
|
||||
theme?: {};
|
||||
messageComponents?: Record<string, Component>;
|
||||
disabled?: Ref<boolean>;
|
||||
allowFileUploads?: Ref<boolean> | boolean;
|
||||
allowedFilesMimeTypes?: Ref<string> | string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user