Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Embed assistants #1489

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/lib/components/chat/AssistantIntroduction.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@
</div>
</div>

<div class="absolute right-3 top-3 md:right-4 md:top-4">
<div
class="absolute right-3 top-3 md:right-4 md:top-4"
class:hidden={$page.data.embeddedAssistantId}
>
<div class="flex flex-row items-center gap-1">
<button
class="flex h-7 items-center gap-1.5 rounded-full border bg-white px-2.5 py-1 text-gray-800 shadow-sm hover:shadow-inner dark:border-gray-700 dark:bg-gray-700 dark:text-gray-300/90 dark:hover:bg-gray-800 max-sm:px-1.5 md:text-sm"
Expand Down Expand Up @@ -161,6 +164,7 @@
goto(`${base}/`);
}}
class="absolute -bottom-6 right-2 inline-flex items-center justify-center text-xs text-gray-600 underline hover:brightness-50 dark:text-gray-400 dark:hover:brightness-110"
class:hidden={$page.data.embeddedAssistantId}
>
<CarbonRenew class="mr-1.5 text-xxs" /> Reset to default model
</button>
Expand Down
8 changes: 5 additions & 3 deletions src/lib/components/chat/ChatWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@
on:drop|preventDefault={() => (onDrag = false)}
/>

<div class="relative min-h-0 min-w-0">
<div class="relative min-w-0" class:min-h-[400px]={$page.data.embeddedAssistantId}>
{#if loginModalOpen}
<LoginModal
on:close={() => {
Expand All @@ -238,11 +238,12 @@
class="scrollbar-custom mr-1 h-full overflow-y-auto"
use:snapScrollToBottom={messages.length ? [...messages] : false}
bind:this={chatContainer}
id="chat-container"
>
<div
class="mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl xl:pt-10"
>
{#if $page.data?.assistant && !!messages.length}
{#if $page.data?.assistant && !!messages.length && !$page.data.embeddedAssistantId}
<a
class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 py-1 pl-1 pr-3 text-sm text-gray-800 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
href="{base}/settings/assistants/{$page.data.assistant._id}"
Expand All @@ -264,7 +265,7 @@

{$page.data.assistant.name}
</a>
{:else if preprompt && preprompt != currentModel.preprompt}
{:else if preprompt && preprompt != currentModel.preprompt && !$page.data.embeddedAssistantId}
<SystemPromptModal preprompt={preprompt ?? ""} />
{/if}

Expand Down Expand Up @@ -455,6 +456,7 @@
</form>
<div
class="mt-2 flex justify-between self-stretch px-1 text-xs text-gray-400/90 max-md:mb-2 max-sm:gap-2"
class:hidden={$page.data.embeddedAssistantId}
>
<p>
Model:
Expand Down
17 changes: 15 additions & 2 deletions src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import type { ConvSidebar } from "$lib/types/ConvSidebar";
import { toolFromConfigs } from "$lib/server/tools";
import { MetricsServer } from "$lib/server/metrics";
import type { ToolFront, ToolInputFile } from "$lib/types/Tool";
import { error } from "@sveltejs/kit";

export const load: LayoutServerLoad = async ({ locals, depends, request }) => {
export const load: LayoutServerLoad = async ({ locals, depends, request, url }) => {
depends(UrlDependency.ConversationList);

const settings = await collections.settings.findOne(authCondition(locals));
Expand Down Expand Up @@ -44,7 +45,7 @@ export const load: LayoutServerLoad = async ({ locals, depends, request }) => {

const assistantActive = !models.map(({ id }) => id).includes(settings?.activeModel ?? "");

const assistant = assistantActive
let assistant = assistantActive
? JSON.parse(
JSON.stringify(
await collections.assistants.findOne({
Expand All @@ -54,6 +55,17 @@ export const load: LayoutServerLoad = async ({ locals, depends, request }) => {
)
: null;

const embeddedAssistantId = url.searchParams.get("embeddedAssistantId");
if (embeddedAssistantId) {
const embeddedAssistant = await collections.assistants.findOne({
_id: new ObjectId(embeddedAssistantId),
});
if (!embeddedAssistant) {
error(404, "Embedded Assistant not found.");
}
assistant = JSON.parse(JSON.stringify(embeddedAssistant));
}

const conversations = await collections.conversations
.find(authCondition(locals))
.sort({ updatedAt: -1 })
Expand Down Expand Up @@ -245,5 +257,6 @@ export const load: LayoutServerLoad = async ({ locals, depends, request }) => {
loginRequired,
loginEnabled: requiresUser,
guestMode: requiresUser && messagesBeforeLogin > 0,
embeddedAssistantId: url.searchParams.get("embeddedAssistantId"),
};
};
98 changes: 59 additions & 39 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -203,49 +203,69 @@
{#if envPublic.PUBLIC_APPLE_APP_ID}
<meta name="apple-itunes-app" content={`app-id=${envPublic.PUBLIC_APPLE_APP_ID}`} />
{/if}
<!-- TODO: remove -->
{#if !$page.data.embeddedAssistantId}
<script
src="http://localhost:5173/chat/api/assistant/{$page.data.assistants.at(-1)
._id}/embed-snippet"
defer
></script>
{/if}
Comment on lines +206 to +213
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to make testing the embedded assistants easier, I'm embedding hf assistant inside the chat-ui. These lines would be removed before merging to main.

you can change {$page.data.assistants.at(-1)._id} this part that grabs the last assistant id to any specific local assistant id if you want

</svelte:head>

{#if !$settings.ethicsModalAccepted && $page.url.pathname !== `${base}/privacy` && PUBLIC_APP_DISCLAIMER === "1"}
<DisclaimerModal />
{/if}

<ExpandNavigation
isCollapsed={isNavCollapsed}
on:click={() => (isNavCollapsed = !isNavCollapsed)}
classNames="absolute inset-y-0 z-10 my-auto {!isNavCollapsed
? 'left-[280px]'
: 'left-0'} *:transition-transform"
/>

<div
class="grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd {!isNavCollapsed
? 'md:grid-cols-[280px,1fr]'
: 'md:grid-cols-[0px,1fr]'} transition-[300ms] [transition-property:grid-template-columns] dark:text-gray-300 md:grid-rows-[1fr]"
>
<MobileNav isOpen={isNavOpen} on:toggle={(ev) => (isNavOpen = ev.detail)} title={mobileNavTitle}>
<NavMenu
conversations={data.conversations}
user={data.user}
canLogin={data.user === undefined && data.loginEnabled}
on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
on:deleteConversation={(ev) => deleteConversation(ev.detail)}
on:editConversationTitle={(ev) => editConversationTitle(ev.detail.id, ev.detail.title)}
/>
</MobileNav>
<nav
class=" grid max-h-screen grid-cols-1 grid-rows-[auto,1fr,auto] overflow-hidden *:w-[280px] max-md:hidden"
{#if !$page.data.embeddedAssistantId}
<ExpandNavigation
isCollapsed={isNavCollapsed}
on:click={() => (isNavCollapsed = !isNavCollapsed)}
classNames="absolute inset-y-0 z-10 my-auto {!isNavCollapsed
? 'left-[280px]'
: 'left-0'} *:transition-transform"
/>

<div
class="grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd {!isNavCollapsed
? 'md:grid-cols-[280px,1fr]'
: 'md:grid-cols-[0px,1fr]'} transition-[300ms] [transition-property:grid-template-columns] dark:text-gray-300 md:grid-rows-[1fr]"
>
<NavMenu
conversations={data.conversations}
user={data.user}
canLogin={data.user === undefined && data.loginEnabled}
on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
on:deleteConversation={(ev) => deleteConversation(ev.detail)}
on:editConversationTitle={(ev) => editConversationTitle(ev.detail.id, ev.detail.title)}
/>
</nav>
{#if currentError}
<Toast message={currentError} />
{/if}
<slot />
</div>
<MobileNav
isOpen={isNavOpen}
on:toggle={(ev) => (isNavOpen = ev.detail)}
title={mobileNavTitle}
>
<NavMenu
conversations={data.conversations}
user={data.user}
canLogin={data.user === undefined && data.loginEnabled}
on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
on:deleteConversation={(ev) => deleteConversation(ev.detail)}
on:editConversationTitle={(ev) => editConversationTitle(ev.detail.id, ev.detail.title)}
/>
</MobileNav>
<nav
class=" grid max-h-screen grid-cols-1 grid-rows-[auto,1fr,auto] overflow-hidden *:w-[280px] max-md:hidden"
>
<NavMenu
conversations={data.conversations}
user={data.user}
canLogin={data.user === undefined && data.loginEnabled}
on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
on:deleteConversation={(ev) => deleteConversation(ev.detail)}
on:editConversationTitle={(ev) => editConversationTitle(ev.detail.id, ev.detail.title)}
/>
</nav>
{#if currentError}
<Toast message={currentError} />
{/if}
<slot />
</div>
{:else}
<div
class="grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd dark:text-gray-300"
>
<slot />
</div>
{/if}
13 changes: 12 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
body: JSON.stringify({
model,
preprompt: $settings.customPrompts[$settings.activeModel],
assistantId: data.assistant?._id,
assistantId: data.embeddedAssistantId ?? data.assistant?._id,
// todo: embeddedAssistantId should be an actual field so that it can check
}),
});

Expand All @@ -63,6 +64,16 @@
files,
});

// embedded assistant
if (data.embeddedAssistantId) {
await goto(
`${base}/conversation/${conversationId}/?embeddedAssistantId=${encodeURIComponent(
data.embeddedAssistantId
)}`
);
return;
}

// invalidateAll to update list of conversations
await goto(`${base}/conversation/${conversationId}`, { invalidateAll: true });
} catch (err) {
Expand Down
153 changes: 153 additions & 0 deletions src/routes/api/assistant/[id]/embed-snippet/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
export async function GET({ params }) {
const { id } = params;
Comment on lines +1 to +2
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very nit: should the route be called embed-snippet.js ?


const script = `(function() {
function resizeIframeToContentSize(iframe) {
Comment on lines +4 to +5
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, i'm curious if there's a way to do this that's more maintainable ? it works great but i can imagine it would be very difficult to modify in the future

i know there's a render() method from svelte you can use to get the HTML for a component, we use something similar in thumbnails (

const renderedComponent = (ChatThumbnail as unknown as SvelteComponent).render({
name: assistant.name,
description: assistant.description,
createdByName: assistant.createdByName,
avatar,
});
const reactLike = html(
"<style>" + renderedComponent.css.code + "</style>" + renderedComponent.html
);
const svg = await satori(reactLike, {
) not sure how it handles javascript and idk if it's worth it but it could let you build this feature in a svelte component

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or simpler, make the JS snippet completely static (a static file) that gets the assistant's id from a property in a <a> link inside the embedding webpage

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<script src="https://link/to/static.js"></script>

<a huggingchat-assistant="66017fca" href="https://hf.co/chat/assistant/66017fca">Chat with {Assistant}</a>

if (iframe.contentWindow) {
const maxHeight = window.innerHeight * 0.8; // 80% of window height
const chatContainerEl = iframe.contentWindow.document.getElementById('chat-container');
if(chatContainerEl){
const contentHeight = chatContainerEl.scrollHeight;
iframe.style.height = Math.max(400, Math.min(contentHeight, maxHeight)) + "px";
}
}
}

document.addEventListener('DOMContentLoaded', function() {
const button = document.createElement('button');
button.className = 'fixed z-[1002] bottom-5 right-5 z-50 px-4 gap-1 py-1 bg-black rounded-full text-white rounded cursor-pointer hover:bg-gray-800 border border-gray-200/30 transition-colors flex items-center focus:outline-none';

const img = document.createElement('img');
img.src = 'https://huggingface.co/chat/huggingchat/logo.svg';
img.alt = 'HuggingChat Logo';
img.className = 'size-5 mr-0.5 flex-none';

const text = document.createTextNode('Chat');

button.appendChild(img);
button.appendChild(text);

const modal = document.createElement('div');
modal.className = 'hidden fixed inset-0 z-[1001] overflow-auto bg-black bg-opacity-50';

const modalContent = document.createElement('div');
modalContent.className = 'bg-white max-w-2xl rounded-xl overflow-hidden bottom-16 right-5 absolute max-sm:left-5 sm:w-[460px] shadow-2xl';

const iframe = document.createElement('iframe');
iframe.className = 'w-full';
iframe.style.height = '400px'; // Set an initial height
iframe.src = \`http://localhost:5173/chat/?embeddedAssistantId=${id}\`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will have to be configurable using the base path I guess in order to work on self-hosted versions


iframe.onload = function() {
const iframeWindow = this.contentWindow;
const iframeDocument = iframeWindow.document;

let lastHeight = 0;

function checkSize() {
const chatContainer = iframeDocument.getElementById('chat-container');
if (chatContainer) {
const newHeight = chatContainer.scrollHeight;
if (newHeight !== lastHeight) {
resizeIframeToContentSize(iframe);
lastHeight = newHeight;
}
}
requestAnimationFrame(checkSize);
}

// Start continuous size checking
checkSize();

// Set up MutationObserver as a backup
const observer = new MutationObserver(() => {
resizeIframeToContentSize(iframe);
});

function initMutationObserver() {
const chatContainer = iframeDocument.getElementById('chat-container');
if (chatContainer) {
console.error('Chat container found, setting up MutationObserver');
observer.observe(chatContainer, { childList: true, subtree: true, attributes: true, characterData: true });
} else {
console.error('Chat container not found, retrying...');
setTimeout(initMutationObserver, 500); // Retry after 500ms
}
}

// Start trying to initialize the MutationObserver
initMutationObserver();

// Resize on load
resizeIframeToContentSize(iframe);

// Add event listener for Escape key in iframe
iframeDocument.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});
};

modalContent.appendChild(iframe);
modal.appendChild(modalContent);

// Store the original overflow style
let originalOverflow;

function toggleModal() {
if (modal.classList.contains('hidden')) {
modal.classList.remove('hidden');
resizeIframeToContentSize(iframe);
// Store the original overflow and prevent scrolling
originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
} else {
modal.classList.add('hidden');
// Restore the original overflow
document.body.style.overflow = originalOverflow;
}
}

button.onclick = toggleModal;

window.onclick = function(event) {
if (event.target == modal) {
toggleModal();
}
};

document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
}
});

// Prevent default scrolling when modal is open
document.addEventListener('scroll', function(event) {
if (!modal.classList.contains('hidden')) {
event.preventDefault();
return false;
}
}, { passive: false });

// Add resize event listener to adjust iframe height when window is resized
window.addEventListener('resize', function() {
if (!modal.classList.contains('hidden')) {
resizeIframeToContentSize(iframe);
}
});

document.body.appendChild(button);
document.body.appendChild(modal);
});
})();
`;

return new Response(script, {
headers: {
"Content-Type": "application/javascript",
"Access-Control-Allow-Origin": "*",
},
});
}
Loading
Loading