Svelte.js
Easily integrate your agent into your Svelte.js frontend
The Svelte package we provide is designed for Svelte 5. If you are interested in support for Svelte 4, please contact us
Initializing a project
If you have not set up a project yet, you can easily initialize a ShadCN-Svelte / Svelte.js project by following the instructions here
Or if you prefer not to use ShadCN-Svelte:
npx sv create
Overview
The logic of the websocket chat is handled by a Context provider. You can install it with this command:
npm i @pompeii-labs/svelte-obsidian
We support multiple project setups out of the box.
Using ShadCN
After you have installed the provider, copy this component into your ShadCN project:
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { getChatContext } from '@pompeii-labs/svelte-obsidian';
import { cn } from '$lib/utils';
import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';
import ArrowDown from 'lucide-svelte/icons/arrow-down';
import ArrowUp from 'lucide-svelte/icons/arrow-up';
const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());
let scrollArea: HTMLDivElement | null = $state(null);
let textareaContainer: HTMLDivElement | null = $state(null);
let textarea: HTMLTextAreaElement | null = $state(null);
let message = $state('');
const SCROLL_THRESHOLD = 196;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
let isAtBottom = $state(true);
// Send the message to the websocket and clear the message state
function handleSubmit() {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
message = '';
textarea?.focus();
}
// Auto resize the textarea
$effect(() => {
message;
if (!textareaContainer) return;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
});
// Handle key press
function handleKeyPress(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
$effect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
});
// Add scroll and touch event listeners
$effect(() => {
if (!scrollArea) return;
const handleScrollAndTouch = () => {
if (!scrollArea) return;
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
if (!scrollArea) return;
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
})
</script>
<div class="h-full overflow-hidden w-full flex flex-col pb-2">
<div class="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
<div bind:this={scrollArea} class="flex-1 flex flex-col overflow-y-auto">
<div class="flex flex-col mt-auto gap-4 p-4">
{#each messages as message}
{#if message.type === 'message'}
{@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
<div class="{data.role === 'user' ? 'bg-secondary text-secondary-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]">
{data.content}
</div>
{/if}
{/each}
{#if awaitingResponse}
<div class="size-3 my-3.5 bg-foreground rounded-full animate-pulse"></div>
{/if}
</div>
<div class={cn("absolute bottom-3 right-7 transition-all duration-150 ease-out",
isAtBottom ? "pointer-events-none translate-y-4 scale-95 opacity-0"
: "translate-y-0 scale-100 opacity-100"
)}>
<Button variant="outline" size="icon" onclick={scrollToBottom}>
<ArrowDown />
</Button>
</div>
</div>
</div>
<div bind:this={textareaContainer} class="relative max-w-2xl container px-0 mx-auto">
<Textarea
bind:ref={textarea}
placeholder="Type a message..."
bind:value={message}
class="w-full h-24 pr-16 resize-none border"
onkeydown={handleKeyPress}
/>
<div class="absolute bottom-2 right-6">
<Button size="icon" onclick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0} class="transition-opacity">
<ArrowUp />
</Button>
</div>
</div>
</div>
You can then use the component inside the MagmaChatProvider
from the svelte-obsidian
package you installed earlier:
<script lang="ts">
import Chat from '$lib/components/chat.svelte';
import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>
<div class="w-screen h-screen">
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>
Using TailwindCSS
This example assumes you have background and foreground tailwind color variables defined
After you have installed the provider, copy this component into your TailwindCSS project:
<script lang="ts">
import { getChatContext } from '@pompeii-labs/svelte-obsidian';
import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';
const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());
let scrollArea: HTMLDivElement | null = $state(null);
let textareaContainer: HTMLDivElement | null = $state(null);
let textarea: HTMLTextAreaElement | null = $state(null);
let message = $state('');
const SCROLL_THRESHOLD = 196;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
let isAtBottom = $state(true);
// Send the message to the websocket and clear the message state
function handleSubmit() {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
message = '';
textarea?.focus();
}
// Auto resize the textarea
$effect(() => {
message;
if (!textareaContainer) return;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
});
// Handle key press
function handleKeyPress(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
$effect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
});
// Add scroll and touch event listeners
$effect(() => {
if (!scrollArea) return;
const handleScrollAndTouch = () => {
if (!scrollArea) return;
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
if (!scrollArea) return;
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
})
</script>
<div class="h-full overflow-hidden w-full flex flex-col pb-2">
<div class="flex-1 flex flex-col overflow-hidden relative container px-0 mx-auto max-w-5xl">
<div bind:this={scrollArea} class="flex-1 flex flex-col overflow-y-auto">
<div class="flex flex-col mt-auto gap-4 p-4">
{#each messages as message}
{#if message.type === 'message'}
{@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
<div class="{data.role === 'user' ? 'bg-foreground/10 text-foreground ml-auto' : 'text-foreground mr-auto'} p-2 rounded-md max-w-[85%]">
{data.content}
</div>
{/if}
{/each}
{#if awaitingResponse}
<div class="size-3 my-3.5 bg-foreground rounded-full animate-pulse"></div>
{/if}
</div>
<div class="absolute bottom-3 right-7 transition-all duration-150 ease-out {isAtBottom ? 'pointer-events-none translate-y-4 scale-95 opacity-0' : 'translate-y-0 scale-100 opacity-100'}">
<button class="border rounded-md size-9 inline-flex items-center justify-center bg-background" onclick={scrollToBottom}>
v
</button>
</div>
</div>
</div>
<div bind:this={textareaContainer} class="relative max-w-2xl container px-0 mx-auto">
<textarea
bind:this={textarea}
placeholder="Type a message..."
bind:value={message}
class="w-full px-4 py-2 rounded-md h-24 pr-16 resize-none border"
onkeydown={handleKeyPress}
></textarea>
<div class="absolute bottom-2 right-6">
<button class="border bg-foreground text-background inline-flex items-center justify-center rounded-md h-9 px-4 py-2 disabled:opacity-50" onclick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0}>
Send
</button>
</div>
</div>
</div>
You can then use the component inside the MagmaChatProvider
from the svelte-obsidian
package you installed earlier:
<script lang="ts">
import Chat from '$lib/components/chat.svelte';
import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>
<div class="w-screen h-screen">
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>
Using CSS Modules
This example assumes you have background and foreground css color variables defined
After you have installed the provider, copy this component into your CSS Modules project:
<script lang="ts">
import { getChatContext } from '@pompeii-labs/svelte-obsidian';
import type { MagmaUserMessage, MagmaAssistantMessage } from '@pompeii-labs/magma/types';
import styles from './chat.module.css';
const { sendMessage, messages, awaitingResponse } = $derived(getChatContext());
let scrollArea: HTMLDivElement | null = $state(null);
let textareaContainer: HTMLDivElement | null = $state(null);
let textarea: HTMLTextAreaElement | null = $state(null);
let message = $state('');
const SCROLL_THRESHOLD = 196;
const MIN_TEXTAREA_HEIGHT = 96;
const MAX_TEXTAREA_HEIGHT = 192;
let isAtBottom = $state(true);
// Send the message to the websocket and clear the message state
function handleSubmit() {
if (message.trim().length === 0 || awaitingResponse) return;
sendMessage(message);
message = '';
textarea?.focus();
}
// Auto resize the textarea
$effect(() => {
message;
if (!textareaContainer) return;
if (!textarea) return;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT);
textareaContainer.style.height = `${newHeight}px`;
textarea.style.height = '100%';
});
// Handle key press
function handleKeyPress(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Scroll to the bottom of the chat
function scrollToBottom() {
if (!scrollArea) return;
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth'
});
}
// Scroll to the bottom of the chat when a new message is added
$effect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.type === 'message' || lastMessage?.type === 'loading') {
scrollToBottom();
}
});
// Add scroll and touch event listeners
$effect(() => {
if (!scrollArea) return;
const handleScrollAndTouch = () => {
if (!scrollArea) return;
const distanceFromBottom = scrollArea.scrollHeight - (scrollArea.scrollTop + scrollArea.clientHeight);
isAtBottom = distanceFromBottom <= SCROLL_THRESHOLD;
};
scrollArea.addEventListener('scroll', handleScrollAndTouch);
scrollArea.addEventListener('touchmove', handleScrollAndTouch);
return () => {
if (!scrollArea) return;
scrollArea.removeEventListener('scroll', handleScrollAndTouch);
scrollArea.removeEventListener('touchmove', handleScrollAndTouch);
};
})
</script>
<div class={styles['chat-container']}>
<div class={styles['scroll-container']}>
<div bind:this={scrollArea} class={styles['scroll-area']}>
<div class={styles['messages-container']}>
{#each messages as message}
{#if message.type === 'message'}
{@const data = message.data as MagmaUserMessage | MagmaAssistantMessage}
<div class="{styles['message']} {data.role === 'user' ? styles['user-message'] : styles['assistant-message']}">
{data.content}
</div>
{/if}
{/each}
{#if awaitingResponse}
<div class={styles['loading-bubble']}></div>
{/if}
</div>
<div class="{styles['scroll-button-container']} {isAtBottom ? styles['scroll-button-container-hidden'] : styles['scroll-button-container-visible']}">
<button class={styles['scroll-button']} onclick={scrollToBottom}>
v
</button>
</div>
</div>
</div>
<div bind:this={textareaContainer} class={styles['textarea-container']}>
<textarea
bind:this={textarea}
placeholder="Type a message..."
bind:value={message}
class={styles['textarea']}
onkeydown={handleKeyPress}
></textarea>
<div class={styles['send-button-container']}>
<button class={styles['send-button']} onclick={handleSubmit} disabled={awaitingResponse || message.trim().length === 0}>
Send
</button>
</div>
</div>
</div>
Also copy the corresponding styles:
.chat-container {
height: 100%;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
}
.scroll-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
width: 100%;
margin-inline: auto;
max-width: 64rem;
}
.scroll-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.messages-container {
display: flex;
flex-direction: column;
margin-top: auto;
gap: 1rem;
padding: 1rem;
}
.message {
padding: 0.5rem;
border-radius: 0.375rem;
max-width: 85%;
line-height: 1.5;
}
.user-message {
background-color: color-mix(in oklab, var(--foreground) 10%, transparent);
color: var(--foreground);
margin-left: auto;
}
.assistant-message {
color: var(--foreground);
margin-right: auto;
}
.loading-bubble {
width: 0.75rem;
height: 0.75rem;
margin: 0.875rem 0rem;
background-color: var(--foreground);
border-radius: calc(infinity * 1px);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.scroll-button-container {
position: absolute;
bottom: 0.75rem;
right: 1.75rem;
transition-property: all;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: 150ms;
}
.scroll-button-container-hidden {
pointer-events: none;
translate: 0 1rem;
scale: 0.95;
opacity: 0;
}
.scroll-button-container-visible {
translate: 0 0;
scale: 1;
opacity: 1;
}
.scroll-button {
border-style: solid;
border-width: 1px;
border-radius: 0.375rem;
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--background);
}
.textarea-container {
position: relative;
max-width: 42rem;
width: 100%;
margin-inline: auto;
}
.textarea {
width: 100%;
padding-inline: 1rem;
padding-block: 0.5rem;
border-radius: 0.375rem;
height: 6rem;
padding-right: 4rem;
resize: none;
border-style: solid;
border-width: 1px;
}
.send-button-container {
position: absolute;
bottom: 0.5rem;
right: 1.5rem;
}
.send-button {
border-style: solid;
border-width: 1px;
background-color: var(--foreground);
color: var(--background);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
height: 2.25rem;
padding-inline: 1rem;
padding-block: 0.5rem;
&:disabled {
opacity: 0.5;
}
}
You can then use the component inside the MagmaChatProvider
from the svelte-obsidian
package you installed earlier:
<script lang="ts">
import Chat from '$lib/components/chat.svelte';
import MagmaChatProvider from '@pompeii-labs/svelte-obsidian';
</script>
<div style="width: 100vw; height: 100vh;">
<MagmaChatProvider agentId="YOUR_AGENT_ID" apiKey="YOUR_API_KEY">
<Chat />
</MagmaChatProvider>
</div>