Skip to content
Merged
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,35 @@ const { text, status } = useStream(
);
```

### Svelte (beta)

Svelte 5 bindings are community-supported and in beta. They need `svelte` and
[`convex-svelte`](https://github.com/get-convex/convex-svelte), with
`setupConvex` called in a parent component. Reactive inputs are passed as
getters. See [`example-svelte/`](./example-svelte/) for the chat app.

```svelte
<script lang="ts">
import { useStream } from "@convex-dev/persistent-text-streaming/svelte";

let { chat, driven }: { chat: Doc<"chats">; driven: boolean } = $props();

const stream = useStream(
api.chat.getChatBody,
new URL(`${convexSiteUrl}/chat-stream`),
() => driven,
() => chat.streamId as StreamId,
// Optional, read when a stream starts:
// () => ({ authToken, headers }),
);
</script>

<p>{stream.text}</p>
```

`stream.text` and `stream.status` update as the stream progresses. The HTTP
request only starts in the browser, never during server-side rendering.

## Design Philosophy

This component balances HTTP streaming with database persistence to try to
Expand Down
18 changes: 18 additions & 0 deletions example-svelte/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Svelte Chat Example App

The chat app from [`example/`](../example/) built with Svelte 5 and the
`@convex-dev/persistent-text-streaming/svelte` bindings. It shares the Convex
backend in `example/convex` and the dependencies in the root `package.json`.

## Running the app

From the repository root:

```bash
npm install
npm run dev # in one terminal
npm run dev:svelte # in another terminal
```

Set `OPENAI_API_KEY` as described in the
[React example's README](../example/README.md#establishing-your-openai_api_key).
13 changes: 13 additions & 0 deletions example-svelte/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Convex Chat Example (Svelte)</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

12 changes: 12 additions & 0 deletions example-svelte/src/App.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import { setupConvex } from "convex-svelte";
import ChatWindow from "./components/ChatWindow.svelte";

const convexUrl = import.meta.env.VITE_CONVEX_URL;
setupConvex(convexUrl);
</script>

<main class="flex min-h-screen flex-col">
<ChatWindow />
</main>

17 changes: 17 additions & 0 deletions example-svelte/src/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@import "tailwindcss";

:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;

color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;

font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

129 changes: 129 additions & 0 deletions example-svelte/src/components/ChatWindow.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { SvelteSet } from "svelte/reactivity";
import MessageItem from "./MessageItem.svelte";
import ServerMessage from "./ServerMessage.svelte";
import { api } from "../../../example/convex/_generated/api";
import type { Id } from "../../../example/convex/_generated/dataModel";

const client = useConvexClient();
const messages = useQuery(api.messages.listMessages, () => ({}));

// Messages this session sent, so only their streams are driven over HTTP.
const drivenIds = new SvelteSet<string>();
let isStreaming = $state(false);
let inputValue = $state("");
let messagesEndRef = $state<HTMLDivElement | null>(null);
let inputRef = $state<HTMLInputElement | null>(null);

function focusInput() {
inputRef?.focus();
}

function scrollToBottom(behavior: ScrollBehavior = "smooth") {
messagesEndRef?.scrollIntoView({ behavior });
}

// Scroll to bottom when window resizes
$effect(() => {
function handleResize() {
scrollToBottom();
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
});

async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!inputValue.trim()) return;

const prompt = inputValue;
inputValue = "";

const chatId: Id<"userMessages"> = await client.mutation(
api.messages.sendMessage,
{ prompt },
);

drivenIds.add(chatId);
isStreaming = true;
}

async function clearAllMessages() {
await client.mutation(api.messages.clearMessages, {});
inputValue = "";
isStreaming = false;
focusInput();
}

function stopStreaming() {
isStreaming = false;
focusInput();
}
</script>

<div class="flex-1 flex flex-col h-full bg-white">
<div class="flex-1 overflow-y-auto py-6 px-4 md:px-8 lg:px-12">
<div class="w-full max-w-5xl mx-auto space-y-6">
{#if messages.isLoading}
<div class="text-center text-gray-500">Loading messages...</div>
{:else if messages.error}
<div class="text-center text-red-500">
Error loading messages: {messages.error.message}
</div>
{:else if messages.data && messages.data.length === 0}
<div class="text-center text-gray-500">
No messages yet. Start the conversation!
</div>
{:else if messages.data}
{#each messages.data as message (message._id)}
<MessageItem {message} isUser={true}>
{message.prompt}
</MessageItem>
<MessageItem {message} isUser={false}>
<ServerMessage
{message}
isDriven={drivenIds.has(message._id)}
{stopStreaming}
{scrollToBottom}
/>
</MessageItem>
{/each}
{/if}
<div bind:this={messagesEndRef}></div>
</div>
</div>

<div class="border-t border-gray-200 py-6 px-4 md:px-8 lg:px-12">
<form onsubmit={handleSubmit} class="w-full max-w-5xl mx-auto">
<div class="flex items-center gap-3">
<input
bind:this={inputRef}
bind:value={inputValue}
placeholder="Type your message..."
disabled={isStreaming}
class="flex-1 p-4 border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none text-base text-black"
/>
<button
type="submit"
disabled={!inputValue.trim() || isStreaming}
class="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
>
Send
</button>
<button
type="button"
disabled={!messages.data || messages.data.length < 2 || isStreaming}
onclick={clearAllMessages}
class="px-8 py-4 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:bg-gray-400 disabled:text-gray-200 font-medium"
>
Clear Chat
</button>
</div>
{#if isStreaming}
<div class="text-xs text-gray-500 mt-2">AI is responding...</div>
{/if}
</form>
</div>
</div>

53 changes: 53 additions & 0 deletions example-svelte/src/components/MessageItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Doc } from "../../../example/convex/_generated/dataModel";
import type { Snippet } from "svelte";

let {
message,
isUser,
children,
}: {
message: Doc<"userMessages">;
isUser: boolean;
children: Snippet;
} = $props();

const formattedDate = $derived(
new Date(message._creationTime).toLocaleDateString(),
);
const formattedTime = $derived(
new Date(message._creationTime).toLocaleTimeString(),
);
</script>

{#if isUser}
<div class="flex items-center gap-4 my-4">
<div class="flex-1 h-px bg-gray-200"></div>
<div class="text-sm text-gray-500">
{formattedDate}
{formattedTime}
</div>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
{/if}

<div class="flex gap-4 {isUser ? 'justify-end' : 'justify-start'}">
<div class="flex gap-4 max-w-[95%] md:max-w-[85%] {isUser && 'flex-row-reverse'}">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full {isUser
? 'bg-blue-600 text-white'
: 'bg-gray-300 text-gray-700'} font-medium text-sm"
>
{isUser ? "U" : "AI"}
</div>

<div
class="rounded-lg px-5 py-4 text-base {isUser
? 'bg-blue-600 text-white'
: 'bg-gray-100 border border-gray-200 text-gray-900'}"
>
{@render children()}
</div>
</div>
</div>

57 changes: 57 additions & 0 deletions example-svelte/src/components/ServerMessage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script lang="ts">
import { getConvexSiteUrl, safeHref } from "@/lib/utils";
import type { StreamId } from "@convex-dev/persistent-text-streaming";
import { useStream } from "@convex-dev/persistent-text-streaming/svelte";
import { api } from "../../../example/convex/_generated/api";
import type { Doc } from "../../../example/convex/_generated/dataModel";
import type { HTMLAnchorAttributes } from "svelte/elements";
import Markdown from "svelte-exmarkdown";

let {
message,
isDriven,
stopStreaming,
scrollToBottom,
}: {
message: Doc<"userMessages">;
isDriven: boolean;
stopStreaming: () => void;
scrollToBottom: () => void;
} = $props();

const stream = useStream(
api.streaming.getStreamBody,
new URL(`${getConvexSiteUrl()}/chat-stream`),
() => isDriven,
() => message.responseStreamId as StreamId,
);

const isCurrentlyStreaming = $derived(
isDriven && (stream.status === "pending" || stream.status === "streaming"),
);

$effect(() => {
if (!isDriven) return;
if (isCurrentlyStreaming) return;
stopStreaming();
});

$effect(() => {
if (!stream.text) return;
scrollToBottom();
});
</script>

<div class="md-answer">
<!-- Raw HTML in the model output renders as text, like react-markdown. -->
<Markdown md={stream.text || "Thinking..."}>
{#snippet a({ href, children, ...rest }: HTMLAnchorAttributes)}
<a {...rest} href={href ? safeHref(href) : undefined}>
{@render children?.()}
</a>
{/snippet}
</Markdown>
{#if stream.status === "error"}
<div class="text-red-500 mt-2">Error loading response</div>
{/if}
</div>
22 changes: 22 additions & 0 deletions example-svelte/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Keeps relative, http(s), and mailto links and drops every other scheme,
// such as `javascript:`. Similar to react-markdown's default URL transform,
// which also allows a few more schemes and applies to images too.
export function safeHref(href: string) {
const scheme = /^([^:/?#]+):/.exec(href)?.[1];
return !scheme || /^(https?|mailto)$/i.test(scheme) ? href : undefined;
}

export function getConvexSiteUrl() {
let convexSiteUrl;
if (import.meta.env.VITE_CONVEX_URL.includes(".cloud")) {
convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(
/\.cloud$/,
".site",
);
} else {
const url = new URL(import.meta.env.VITE_CONVEX_URL);
url.port = String(Number(url.port) + 1);
convexSiteUrl = url.toString();
}
return convexSiteUrl;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions example-svelte/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import App from "./App.svelte";
import "./app.css";
import { mount } from "svelte";

const app = mount(App, {
target: document.getElementById("app")!,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default app;

11 changes: 11 additions & 0 deletions example-svelte/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_CONVEX_URL: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

Loading
Loading