Skip to content
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
59 changes: 59 additions & 0 deletions example-svelte/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Svelte Example - Persistent Text Streaming

This is a Svelte 5 example demonstrating the `@convex-dev/persistent-text-streaming` component with Svelte bindings.

## Setup

1. Make sure you have the Convex backend running (shared with the React example):

```bash
cd ../example
npx convex dev
```

2. Install dependencies and run the Svelte example:

```bash
bun install
bun run dev
```

## Features

- Real-time chat interface with AI responses
- HTTP streaming for immediate token display
- Convex sync as persistent fallback for page reloads
- Svelte 5 runes for reactivity

## Key Files

- `src/App.svelte` - Main app component with Convex setup
- `src/components/ChatWindow.svelte` - Chat interface
- `src/components/MessageItem.svelte` - Individual message display
- `src/components/ServerMessage.svelte` - Server response with streaming using `useStream`

## Usage

The `useStream` hook from `@convex-dev/persistent-text-streaming/svelte` provides:

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

let { isDriven, streamId } = $props();

const stream = useStream(
api.streaming.getStreamBody, // Query function reference
new URL("/api/stream"), // HTTP streaming endpoint
() => isDriven, // Getter for whether this client drives the stream
() => streamId, // Getter for stream ID
);

// Reactive properties
// stream.text - Current text content
// stream.status - "pending" | "streaming" | "done" | "error"
</script>
```

Note: The `getDriven` and `getStreamId` parameters are getter functions (not values) to enable Svelte's reactivity system to track changes.

1,011 changes: 1,011 additions & 0 deletions example-svelte/bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions example-svelte/convex
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>

28 changes: 28 additions & 0 deletions example-svelte/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "persistent-text-streaming-svelte-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.0",
"svelte": "^5.33.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.2.0"
},
"dependencies": {
"@convex-dev/persistent-text-streaming": "file:..",
"clsx": "^2.1.1",
"convex": "^1.29.0",
"convex-svelte": "^0.0.12",
"marked": "^15.0.0",
"tailwind-merge": "^3.3.0"
}
}

Comment on lines +11 to +28
Copy link
Member

Choose a reason for hiding this comment

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

is it possible to put these dependencies in the parent so there's only one node_modules folder to deal with?

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;
}

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

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

// Use a plain object for better Svelte reactivity tracking
let drivenIds = $state<Record<string, boolean>>({});
let isStreaming = $state(false);
let inputValue = $state("");
let messagesEndRef = $state<HTMLDivElement | null>(null);
let inputRef = $state<HTMLInputElement | null>(null);

function isDriven(id: string): boolean {
return drivenIds[id] === true;
}

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 },
);

// Use object spread to trigger reactivity
drivenIds = { ...drivenIds, [chatId]: true };
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={isDriven(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 "../../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>

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

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"),
);

// Stop streaming when done
$effect(() => {
if (!isDriven) return;
if (isCurrentlyStreaming) return;
stopStreaming();
});

// Scroll to bottom when text updates
$effect(() => {
if (!stream.text) return;
scrollToBottom();
});

// Parse markdown
const htmlContent = $derived(
stream.text ? marked.parse(stream.text) : "<p>Thinking...</p>",
);
</script>

<div class="md-answer prose prose-sm max-w-none">
{#await htmlContent then html}
{@html html}
{/await}
{#if stream.status === "error"}
<div class="text-red-500 mt-2">Error loading response</div>
{/if}
</div>

Loading