Skip to content

Commit

Permalink
Add chatbot iframe page to embed in external apps/websites (#29)
Browse files Browse the repository at this point in the history
* chore: initial chatbot iframe page

* feat: use uuid in path + avoid login check

* chore: typo + unused vars

* feat: adding Layout control

* feat: adding ChatZone component

* fix: define page meta missing on iframe

* feat: iframe chatbot full screen + prettier fix

* feat: add iframe visibility settings via `brevia_app.public_iframe`

* Fix: Eslint and Embedded Chatbar fixed on bottom

* fix: prettier

* Fix: adding scrolling chat when embedded

* fix: prettier

* fix: position absolute disappeared

* feat: adding auto scroll on submit and response

* fix: prettier + eslint

---------

Co-authored-by: Davide Rovai <davide.rovai@atlasconsulting.it>
  • Loading branch information
stefanorosanelli and BuonDavide authored Nov 27, 2024
1 parent e8a2493 commit 79461fe
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 244 deletions.
12 changes: 3 additions & 9 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
<template>
<ClientOnly>
<div class="h-[100dvh] flex flex-col">
<MainHeader class="ml-0.5 w-full fixed z-10 bg-neutral-50 shadow"></MainHeader>
<NuxtLayout>
<UIXProgressLinear v-if="modalStore.isLoadingPage" class="z-10 absolute top-24 w-full" />

<div class="grow mt-24 pt-3 sm:pt-8 pb-14 px-4 sm:px-6 w-full mx-auto" :class="{ 'max-w-3xl': $route.path !== '/' }">
<NuxtPage />
</div>

<MainFooter />
</div>
</NuxtLayout>

<AppModal v-if="$isActiveModal()" />
</ClientOnly>
</template>

<script setup lang="ts">
import { useModalStore } from '~~/store/modal';
const { $isActiveModal } = useNuxtApp();
const { locale, t } = useI18n();
const modalStore = useModalStore();
Expand Down
267 changes: 267 additions & 0 deletions components/ChatZone.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
<template>
<div v-if="dialog.length">
<div
ref="dialogZone"
class="pt-6 pb-4 space-y-3"
:class="isEmbedded ? 'h-[85vh] px-4 sm:px-6 overflow-y-auto w-full' : 'bg-white px-4 relative shadow-md rounded'"
>
<div class="flex flex-col space-y-6 pb-4">
<div
v-for="(item, i) in dialog"
:key="i"
class="chat-balloon space-y-2"
:class="{ 'bg-pink-800': item.error }"
@mouseover="
showResponseMenu = true;
hovered = i;
"
@mouseleave="showResponseMenu = false"
>
<div class="flex space-x-3 justify-between">
<p class="text-xs">{{ item.who }}</p>
<div class="chat-balloon-status" :class="{ busy: isBusy && i === dialog.length - 1 }"></div>
</div>
<div class="break-words rich-text" v-html="formatResponse(item.message, responseFormat)"></div>
<!--MENU CONTESTUALE-->
<div
v-if="canSeeDocs && i === dialog.length - 1 && showResponseMenu && hovered === i"
class="px-2 py-0.5 absolute -bottom-5 right-4 z-50 bg-neutral-700 rounded-full flex flex-row"
>
<div
class="px-1.5 pb-1 hover:bg-neutral-600 hover:rounded-full hover:cursor-pointer"
:title="$t('SHOW_DOCUMENTS_FOUND')"
@click="$openModal('ChatDocuments', { session_id: sessionId, documents: docs })"
>
<Icon name="fluent:document-question-mark-16-regular"></Icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="space-y-4" :class="{ 'bottom-6 left-0 px-4 absolute grow w-full': isEmbedded }">
<div class="flex space-x-4">
<input
ref="input"
v-model.trim="prompt"
type="text"
class="grow text-lg p-2 rounded border border-sky-500 disabled:bg-neutral-100 disabled:border-neutral-300 shadow-md disabled:shadow-none"
:disabled="isBusy || messagesLeft == '0'"
@keydown.enter="submit"
/>
<button class="px-6 button shadow-md disabled:shadow-none" :disabled="isBusy || messagesLeft == '0'" @click="submit">
<span class="sm:hidden">›</span>
<span class="hidden sm:inline">{{ $t('SEND') }}</span>
</button>
</div>
<slot name="messageCounter"></slot>
</div>
</template>

<script setup lang="ts">
const { $openModal } = useNuxtApp();
const config = useRuntimeConfig();
const { formatResponse, llmResponseFormat } = useResponseFormat();
const props = defineProps({
isDemoChatbot: Boolean,
messagesLeft: { type: String, default: '' },
collection: {
type: Object as PropType<{ name?: string; uuid?: string; cmetadata?: any }>,
default: () => ({ name: '', uuid: '', cmetadata: {} }),
},
isEmbedded: Boolean,
});
const emit = defineEmits(['updateLeft']);
interface DialogItem {
who: string;
message: string;
error: boolean;
}
const { t } = useI18n();
const dialogZone = ref();
const isBusy = ref(false);
const prompt = ref('');
const input = ref<HTMLElement | null>(null);
const dialog = ref<DialogItem[]>([]);
const docs = ref<any>([]);
const historyId = ref('');
const canSeeDocs = ref(false);
let docsJsonString = '';
let responseEnded = false;
let currIdx = 0;
const hovered = ref(-1);
const showResponseMenu = ref(true);
let sessionId = '';
let collectionName = '';
const responseFormat = ref('text');
onBeforeMount(async () => {
collectionName = props.collection.name || '';
responseFormat.value = llmResponseFormat(props.collection.cmetadata?.qa_completion_llm);
sessionId = crypto.randomUUID();
isBusy.value = false;
updateLeftMessages();
});
watch(isBusy, (val) => {
if (!val) {
setTimeout(() => {
input.value?.focus();
}, 100);
}
});
// methods
const formatDialogItem = (who: string, message: string, error = false): DialogItem => {
return {
who,
message,
error,
};
};
const submit = async () => {
if (!prompt.value) return;
isBusy.value = true;
if (props.isEmbedded) nextTick(() => dialogZone.value.scrollTo({ top: dialogZone.value.scrollHeight, behavior: 'smooth' }));
dialog.value.push(formatDialogItem('YOU', prompt.value));
dialog.value.push(formatDialogItem('BREVIA', ''));
currIdx = dialog.value.length - 1;
try {
await streamingFetchRequest();
isBusy.value = false;
} catch (error) {
isBusy.value = false;
showErrorInDialog(currIdx);
console.log(error);
}
};
const streamingFetchRequest = async () => {
const question = prompt.value;
prompt.value = '';
docs.value = [];
historyId.value = '';
docsJsonString = '';
responseEnded = false;
canSeeDocs.value = false;
const response = await fetch('/api/brevia/chat', {
method: 'POST',
headers: {
'Content-type': 'application/json',
'X-Chat-Session': sessionId,
},
body: JSON.stringify({
question,
collection: collectionName,
source_docs: true,
streaming: true,
}),
});
const reader = response?.body?.getReader();
if (reader) {
for await (const chunk of readChunks(reader)) {
const text = new TextDecoder().decode(chunk);
handleStreamText(text);
}
parseDocsJson();
if (props.isDemoChatbot) await updateLeftMessages();
}
if (props.isDemoChatbot) await updateLeftMessages();
};
const readChunks = (reader: ReadableStreamDefaultReader) => {
return {
async *[Symbol.asyncIterator]() {
let readResult = await reader.read();
while (!readResult.done) {
yield readResult.value;
readResult = await reader.read();
}
},
};
};
const handleStreamText = (text: string) => {
if (text.includes('[{"chat_history_id":') || text.includes('[{"page_content":')) {
const idx1 = text.indexOf('[{"chat_history_id":');
const idx2 = text.indexOf('[{"page_content":');
const idx = Math.max(idx1, idx2);
dialog.value[currIdx].message += text.slice(0, idx);
responseEnded = true;
docsJsonString += text.slice(idx);
} else if (responseEnded) {
docsJsonString += text;
} else if (text.startsWith('{"error":')) {
try {
const err = JSON.parse(text);
console.error(`Error response from API "${err?.error}"`);
showErrorInDialog(currIdx);
} catch (e) {
return console.error(e);
}
} else {
dialog.value[currIdx].message += text;
if (props.isEmbedded) nextTick(() => dialogZone.value.scrollTo({ top: dialogZone.value.scrollHeight, behavior: 'smooth' }));
}
};
const parseDocsJson = () => {
try {
if (!docsJsonString) {
console.error('No docs found in response');
dialog.value[currIdx].error = true;
return;
}
const parsed = JSON.parse(docsJsonString);
if (parsed?.[0]?.chat_history_id) {
const item = parsed?.shift() || {};
historyId.value = item?.chat_history_id || '';
}
docs.value = parsed;
canSeeDocs.value = true;
} catch (e) {
return console.error(e);
}
};
const showErrorInDialog = (index: number) => {
const dialogItem = formatDialogItem('BREVIA', t('SOMETHING_WENT_WRONG'), true);
if (index) {
dialog.value[index] = dialogItem;
return;
}
dialog.value.push(dialogItem);
};
const updateLeftMessages = async () => {
if (!props.isDemoChatbot) {
return;
}
const today = new Date().toISOString().substring(0, 10);
const query = `min_date=${today}&collection=${props.collection?.name}`;
try {
const response = await fetch(`/api/brevia/chat_history?${query}`);
const data = await response.json();
const numItems = data?.meta?.pagination?.count || 0;
const left = Math.max(0, parseInt(config.public.demo.maxChatMessages) - parseInt(numItems));
emit('updateLeft', left);
} catch (error) {
console.log(error);
}
};
</script>
19 changes: 19 additions & 0 deletions components/Form/ChatbotAdvanced.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@
</div>
</Transition>

<!--UI-->
<div class="flex border-b-4 border-primary hover:cursor-pointer" @click="openCloseSection('UI')">
<p class="mx-auto font-bold uppercase text-xl">UI</p>
<Icon :name="uiVisible ? 'material-symbols:keyboard-arrow-up' : 'material-symbols:keyboard-arrow-down'" size="32" />
</div>
<Transition name="section-fade">
<div v-if="uiVisible" class="space-y-4">
<div>
Brevia App
<JsonEditorVue v-model="breviaAppOptions" :mode="Mode.text" />
</div>
</div>
</Transition>

<div v-if="error" class="p-3 bg-neutral-100 text-center font-semibold text-brand_primary">
{{ $t('AN_ERROR_OCCURRED_PLEASE_RETRY') }}
</div>
Expand Down Expand Up @@ -150,6 +164,9 @@ const docMetadata = ref(collection.cmetadata.documents_metadata);
const docDefaults = ref(collection.cmetadata.metadata_defaults);
const upldOptions = ref(collection.cmetadata.file_upload_options);
const lnkLdOptions = ref(collection.cmetadata.link_load_options);
// Brevia App
const uiVisible = ref(true);
const breviaAppOptions = ref(collection.cmetadata.brevia_app);
const integration = useIntegration();
Expand Down Expand Up @@ -196,6 +213,8 @@ const updateMetadataItems = () => {
handleJsonMeta('metadata_defaults', docDefaults.value);
handleJsonMeta('file_upload_options', upldOptions.value);
handleJsonMeta('link_load_options', lnkLdOptions.value);
// UI
handleJsonMeta('brevia_app', breviaAppOptions.value);
};
const handleIntMeta = (name: string, value: any) => {
Expand Down
13 changes: 13 additions & 0 deletions layouts/default.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<div class="h-[100dvh] flex flex-col">
<MainHeader class="ml-0.5 w-full fixed z-10 bg-neutral-50 shadow"></MainHeader>
<div class="grow mt-24 pt-3 sm:pt-8 pb-14 px-4 sm:px-6 w-full mx-auto" :class="{ 'max-w-3xl': route.path !== '/' }">
<NuxtPage />
</div>
<MainFooter />
</div>
</template>

<script setup lang="ts">
const route = useRoute();
</script>
12 changes: 12 additions & 0 deletions layouts/iframe.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<template>
<div class="h-[100dvh] flex flex-col" :class="'bg-' + background">
<div class="grow pt-3 sm:pt-8 pb-14 w-full">
<NuxtPage />
</div>
</div>
</template>

<script setup lang="ts">
const route = useRoute();
const background = route.meta.background;
</script>
2 changes: 1 addition & 1 deletion middleware/auth.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
'/privacy/cookie-policy',
);
}
if (publicPages.includes(to.path)) {
if (publicPages.includes(to.path) || to.path.startsWith('/chatbot-iframe/')) {
return;
}

Expand Down
Loading

0 comments on commit 79461fe

Please sign in to comment.