Chat apps are very popular since ChatGPT got viral, and so many tools were created for building Chat apps, e.g. langchain.
However, almost all AI chat apps I've seen are created with React.
Svelte is my favourite and what I used to create Kunkun, and I have to implement a AI chat app with svelte.
Dependencies
-
Vercel's AI sdk
- For svelte: https://sdk.vercel.ai/docs/getting-started/svelte
- Vercel's AI sdk is very powerful with many features, and most importantly, supports svelte.
-
svelte-exmarkdown
-
svelte-exmarkdown
is extensible, supports rehype and remark plugins, and also custom renderer. - Without these features, I won't be able to create code highlight, latex, or copy button on code blocks.
- Docs
-
Markdown Component
```svelte title="Markdown.svelte"
import rehypeShikiFromHighlighter from "@shikijs/rehype/core" import rehypeClassNames from "rehype-class-names" import rehypeKatex from "rehype-katex" import remarkMath from "remark-math" import { createHighlighterCoreSync } from "shiki/core" import { createJavaScriptRegexEngine } from "shiki/engine/javascript" import svelte from "shiki/langs/svelte.mjs" import ts from "shiki/langs/typescript.mjs" import githubDarkDefault from "shiki/themes/github-dark-default.mjs" import Markdown from "svelte-exmarkdown" import type { Plugin } from "svelte-exmarkdown" import Pre from "./Pre.svelte" const addClass: Plugin = { rehypePlugin: [ rehypeClassNames, { pre: "p-4 rounded-md overflow-auto" } ] } const shikiPlugin = { rehypePlugin: [ rehypeShikiFromHighlighter, createHighlighterCoreSync({ themes: [githubDarkDefault], langs: [ ts, svelte, ], engine: createJavaScriptRegexEngine() }), { theme: "github-dark-default" } ] } satisfies Plugin const plugins: Plugin[] = [ shikiPlugin, { remarkPlugin: [remarkMath], rehypePlugin: [rehypeKatex] }, addClass, { renderer: { pre: Pre } } ] let { md }: { md: string } = $props()
The `pre` custom renderer is optional. I used a custom `pre` renderer to add a copy button.
```svelte title="Pre.svelte"
<script lang="ts">
import { Button } from '@kksh/svelte5';
import { CopyIcon } from 'lucide-svelte';
import type { Snippet } from 'svelte';
let pre: HTMLPreElement;
let {
children,
class: className,
style
}: { children: Snippet; class?: string; style?: string } = $props();
</script>
<div class="relative">
<pre class={className} {style} bind:this={pre}>{@render children()}</pre>
<Button
size="icon"
variant="outline"
onclick={() => navigator.clipboard.writeText(pre.textContent ?? '')}
class="absolute right-2 top-2"
>
<CopyIcon />
</Button>
</div>
Server
https://sdk.vercel.ai/docs/getting-started/svelte contains example to create server with ai sdk with SvelteKit server routes.
However, I prefer using a separate server for this. My current favourite server framework is Hono.
See https://sdk.vercel.ai/cookbook/api-servers/hono#hono for AI Sdk's Hono sample code.
Sample Hono Code ```ts title="server.ts" import { openai } from "@ai-sdk/openai"; import { serve } from "@hono/node-server"; import { streamText } from "ai"; import { Hono } from "hono"; import { stream } from "hono/streaming"; const app = new Hono(); app.post("/", async (c) => { const result = streamText({ model: openai("gpt-4o"), prompt: "Invent a new holiday and describe its traditions.", }); // Mark the response as a v1 data stream: c.header("X-Vercel-AI-Data-Stream", "v1"); c.header("Content-Type", "text/plain; charset=utf-8"); return stream(c, (stream) => stream.pipe(result.toDataStream())); }); serve({ fetch: app.fetch, port: 8080 }); ```Chat Page
The SERVER_URL_URL
constant is the url to the server. https://<domain>/api/chat
.
```svelte title="chat/+page.svelte"
import { SERVER_URL_URL } from '@/constants';
import { useChat } from '@ai-sdk/svelte';
import { Markdown } from '@kksh/ui/markdown';
import { Button, Textarea, Card, Input, Badge } from '@kksh/svelte5';
import { ScrollArea } from '@kksh/ui';
import { IconMultiplexer } from '@kksh/ui';
import { IconEnum } from '@kksh/api/models';
import { GlobeIcon } from 'lucide-svelte';
import sampleMessages from './sample-messages.json';
import 'katex/dist/katex.min.css';
import { onMount } from 'svelte';
import Inspect from 'svelte-inspect-value';
import { dev } from '$app/environment';
import { preferences } from '@/stores/preference';
import { fade } from 'svelte/transition';
import { type UIMessage } from 'ai';
const { data } = $props();
let usage = $state<{
completionTokens: number;
promptTokens: number;
totalTokens: number;
}>({
completionTokens: 0,
promptTokens: 0,
totalTokens: 0
});
const { input, handleSubmit, messages, setMessages } = useChat({
api: SERVER_URL_URL,
headers: {
Authorization: `Bearer ${data.session?.access_token}`
},
onResponse: (response) => {
console.log(response.headers);
},
onFinish: (msg, options) => {
console.log('finished', msg, options);
usage = options.usage;
}
});
let messagesContainer: HTMLDivElement | null = $state(null);
$effect(() => {
if ($messages.length > 0) {
scrollToBottom();
}
});
onMount(() => {
scrollToBottom();
if (dev) {
setMessages(sampleMessages as unknown as UIMessage[]);
}
});
const scrollToBottom = () => {
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: 'smooth'
});
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Handle Ctrl+K to clear messages
if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
setMessages([]);
$input = '';
return;
}
// Handle Enter for submission (but not with Shift)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
}
};
/* -------------------------------------------------------------------------- */
/* Textarea Expand */
/* -------------------------------------------------------------------------- */
let textareaEl: HTMLTextAreaElement | null = $state(null);
const autoResize = (textarea: HTMLTextAreaElement) => {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 10 * 24); // Assuming ~24px per row
textarea.style.height = newHeight + 'px';
};
const handleInput = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement;
autoResize(textarea);
};
{#snippet usageDisplay()}
Tokens used: {usage.totalTokens}
Prompt tokens: {usage.promptTokens}
Completion tokens: {usage.completionTokens}
{/snippet} {#if $preferences.developerMode} {/if} {#each $messages as message} {#if message.role === 'user'}
{message.content}
{:else}
{message.role}
{/if}
{/each}
<span>Search</span>
{@render usageDisplay()}
Top comments (0)