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:
oleg
2024-07-09 13:45:41 +02:00
committed by GitHub
parent 501bcd80ff
commit df783151b8
32 changed files with 2309 additions and 940 deletions

View File

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

View File

@@ -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, {

View File

@@ -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}`,

View 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>

View File

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

View File

@@ -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 || '&lt;Empty response&gt;';
@@ -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>

View File

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

View File

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

View File

@@ -1 +1,2 @@
@import 'tokens';
@import 'markdown';

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

View File

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

View File

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

View File

@@ -16,4 +16,5 @@ interface ChatMessageBase {
createdAt: string;
transparent?: boolean;
sender: 'user' | 'bot';
files?: File[];
}

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