Codync
免費、開源的 1:1 替代方案,取代 Grok Bot 與 Muse。從 iPhone、Mac 或 Linux 桌面將 Claude Code、Codex、Cursor 與 40+ 程式碼代理人作為持續機器人傳訊。群組聊天、主題串…
AI 翻譯
💬 A performant virtual chat component for Svelte applications - efficiently render large scrollable chat interfaces with minimal memory usage
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗A high-performance virtual chat viewport for Svelte 5. Purpose-built for LLM conversations, support chat, and any message-based UI. Renders only visible messages to the DOM while maintaining smooth follow-bottom behavior, streaming stability, and history prepend with anchor preservation.
Chat UIs are not generic lists. They have specific behaviors that general-purpose virtual list components handle poorly:
@humanspeak/svelte-virtual-chat is opinionated about these behaviors so you don't have to fight a generic abstraction.
prefers-reduced-motion$state, $derived, $effect, and snippetsonDebugInfo callback (total, DOM count, measured, range, following state)esm-env, used by the compiled output for SSR detection)# Using pnpm (recommended)
pnpm add @humanspeak/svelte-virtual-chat
# Using npm
npm install @humanspeak/svelte-virtual-chat
# Using yarn
yarn add @humanspeak/svelte-virtual-chat
Full documentation lives at virtualchat.svelte.page, including guides for LLM streaming, history loading, scroll behavior, and accessibility.
Every example below is also a live, interactive demo:
| Demo | What it shows |
|---|---|
| Basic Chat | Follow-bottom, scroll-away, and message virtualization |
| LLM Streaming | Token-by-token growth with a pinned viewport |
| History Loading | Prepending older messages with scroll preservation |
| Header & Footer | Persistent content and typing indicators |
<script lang="ts">
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
type Message = { id: string; role: string; content: string }
let messages: Message[] = $state([
{ id: '1', role: 'assistant', content: 'Hello! How can I help?' },
{ id: '2', role: 'user', content: 'Tell me about Svelte.' }
])
</script>
<div class="h-[600px]">
<SvelteVirtualChat
{messages}
getMessageId={(msg) => msg.id}
estimatedMessageHeight={72}
containerClass="h-full"
viewportClass="h-full"
>
{#snippet renderMessage(message, index)}
<div class="p-4 border-b">
<strong>{message.role}</strong>
<p>{message.content}</p>
</div>
{/snippet}
</SvelteVirtualChat>
</div>
| Prop | Type | Default | Description |
|---|---|---|---|
messages | TMessage[] | Required | Array of messages in chronological order (oldest first) |
getMessageId | (msg: TMessage) => string | Required | Extract a unique, stable ID from a message |
renderMessage | Snippet<[TMessage, number]> | Required | Snippet that renders a single message |
header | Snippet | - | Persistent content above all messages (always in DOM) |
footer | Snippet | - | Persistent content below all messages (always in DOM) |
estimatedMessageHeight | number | 72 | Height estimate in pixels for unmeasured messages |
followBottomThresholdPx | number | 48 | Distance from bottom to consider "at bottom" |
overscan | number | 6 | Extra messages rendered above/below the viewport |
onNeedHistory | () => void | Promise<void> | - | Called when user scrolls near top (load older messages) |
onFollowBottomChange | (isFollowing: boolean) => void | - | Called when follow-bottom state changes |
onDebugInfo | (info: SvelteVirtualChatDebugInfo) => void | - | Called with live stats on every scroll/render update |
containerClass | string | '' | CSS class for the outermost container |
viewportClass | string | '' | CSS class for the scrollable viewport |
viewportLabel |
Bind the component to access these methods:
<script lang="ts">
let chat: ReturnType<typeof SvelteVirtualChat>
</script>
<SvelteVirtualChat bind:this={chat} ... />
<button onclick={() => chat.scrollToBottom({ smooth: true })}> Scroll to bottom </button>
| Method | Signature | Description |
|---|---|---|
scrollToBottom | (options?: { smooth?: boolean }) => void | Scroll the viewport to the bottom |
scrollToMessage | (id: string, options?: { smooth?: boolean }) => void | Scroll to a specific message by its ID |
isAtBottom | () => boolean | Check if the viewport is currently following bottom |
getDebugInfo | () => SvelteVirtualChatDebugInfo | Get a snapshot of current debug stats |
The component handles streaming natively. As a message grows token by token, ResizeObserver detects the height change and the viewport stays pinned to bottom without jitter.
Pair with @humanspeak/svelte-markdown for rich markdown rendering with streaming support:
<script lang="ts">
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
import SvelteMarkdown from '@humanspeak/svelte-markdown'
type Message = {
id: string
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
let messages: Message[] = $state([])
</script>
<SvelteVirtualChat
{messages}
getMessageId={(msg) => msg.id}
containerClass="h-[600px]"
viewportClass="h-full"
>
{#snippet renderMessage(message, index)}
<div class="p-4 border-b">
{#if message.role === 'assistant'}
<SvelteMarkdown source={message.content} streaming={message.isStreaming ?? false} />
{:else}
<p>{message.content}</p>
{/if}
</div>
{/snippet}
</SvelteVirtualChat>
Load older messages when the user scrolls near the top. The component preserves the user's scroll position during prepend operations.
<script lang="ts">
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
let messages = $state([...recentMessages])
let isLoading = false
async function loadHistory() {
if (isLoading) return
isLoading = true
const older = await fetchOlderMessages()
messages = [...older, ...messages]
isLoading = false
}
</script>
<SvelteVirtualChat
{messages}
getMessageId={(msg) => msg.id}
onNeedHistory={loadHistory}
containerClass="h-[600px]"
viewportClass="h-full"
>
{#snippet renderMessage(message, index)}
<div class="p-4">{message.content}</div>
{/snippet}
</SvelteVirtualChat>
Add persistent content above and below messages without injecting fake entries into the messages array. Both snippets are always in the DOM (never virtualized) and scroll with the content.
Footer height changes automatically trigger follow-bottom snapping — perfect for typing indicators:
<SvelteVirtualChat
{messages}
getMessageId={(msg) => msg.id}
containerClass="h-[600px]"
viewportClass="h-full"
>
{#snippet header()}
<div class="p-3 text-center text-sm text-gray-500">Beginning of conversation</div>
{/snippet}
{#snippet renderMessage(message, index)}
<div class="p-4 border-b">
<p>{message.content}</p>
</div>
{/snippet}
{#snippet footer()}
{#if isTyping}
<div class="p-3 text-sm text-gray-500">Assistant is typing...</div>
{/if}
{/snippet}
</SvelteVirtualChat>
The scrollable viewport is a keyboard-reachable landmark out of the box:
role="region" + tabindex="0", named via the viewportLabel prop (default "Chat messages")ArrowUp/ArrowDown (40px), PageUp/PageDown/Space/Shift+Space (85% of viewport), Home (oldest message, disengages follow), End (newest message, re-engages follow)ArrowDown during a stream never breaks followprefers-reduced-motion: reducescrollTo from assistive tooling or app code disengages follow instead of being yanked back to the bottomBecause messages are virtualized (off-screen messages don't exist in the DOM), announce new messages yourself with a polite aria-live region, and pair onFollowBottomChange with a visible "jump to newest" button. See the Accessibility guide for patterns.
The onDebugInfo callback provides real-time visibility into the component's internal state:
<script lang="ts">
import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
import type { SvelteVirtualChatDebugInfo } from '@humanspeak/svelte-virtual-chat'
let stats: SvelteVirtualChatDebugInfo | null = $state(null)
</script>
<SvelteVirtualChat
{messages}
getMessageId={(msg) => msg.id}
onDebugInfo={(info) => (stats = info)}
...
/>
{#if stats}
<div>
Total: {stats.totalMessages} | In DOM: {stats.renderedCount} | Following: {stats.isFollowingBottom}
</div>
{/if}
| Field | Type | Description |
|---|---|---|
totalMessages | number | Total messages in the array |
renderedCount | number | Messages currently in the DOM |
measuredCount | number | Messages with measured heights |
startIndex | number | First rendered index |
endIndex | number | Last rendered index |
totalHeight | number | Calculated total content height (px) |
scrollTop | number | Current scroll position (px) |
viewportHeight | number | Viewport height (px) |
isFollowingBottom | boolean | Whether the viewport is pinned to bottom |
averageHeight | number | Average measured message height (px) |
heightCacheVersion | number | Monotonic counter of height-cache mutations (each measurement, removal, or clear adds 1) |
Full type exports for building typed wrappers and extensions:
import type {
SvelteVirtualChatProps,
SvelteVirtualChatDebugInfo,
ScrollToBottomOptions,
ScrollToMessageOptions,
VisibleRange,
ScrollAnchor
} from '@humanspeak/svelte-virtual-chat'
// Utility exports
import {
ChatHeightCache,
captureScrollAnchor,
restoreScrollAnchor
} from '@humanspeak/svelte-virtual-chat'
The component uses standard top-to-bottom geometry (no inverted lists):
scrollTop to scrollTop + viewportHeight, plus an overscan buffertransform: translateY() inside a content div sized to the total calculated heightflex-direction: column; justify-content: flex-end pushes them to the bottomWith 10,000 messages, the DOM contains ~15-25 elements instead of 10,000.
| Metric | Value |
|---|---|
| DOM nodes with 1,000 messages | ~15-25 (viewport + overscan) |
| DOM nodes with 10,000 messages | ~15-25 (same) |
| Follow-bottom correction | Same frame as the growth, before paint |
| Height measurement | Layout offsets, ResizeObserver-triggered |
| Streaming height updates | Coalesced — one reactive cascade per frame |
| Package | Description |
|---|---|
| @humanspeak/svelte-markdown | Markdown renderer with LLM streaming mode (~1.6ms per update) |
| @humanspeak/svelte-virtual-list | General-purpose virtual list for non-chat use cases |
Part of the Humanspeak family of runes-native Svelte 5 packages:
| Package | Description |
|---|---|
| @humanspeak/svelte-markdown | Runtime markdown renderer for Svelte |
| @humanspeak/svelte-virtual-list | Virtual scrolling for Svelte |
| @humanspeak/svelte-motion | Framer Motion for Svelte 5 |
| @humanspeak/svelte-headless-table | Headless data tables for Svelte |
| @humanspeak/svelte-diff | Diff comparison for Svelte |
| @humanspeak/svelte-purify | HTML sanitisation for Svelte |
| @humanspeak/svelte-virtual-chat — this package | Virtual chat viewport for Svelte 5 |
| @humanspeak/memory-cache | In-memory cache for TypeScript |
| @humanspeak/svelte-json-view-lite | JSON tree viewer for Svelte 5 |
| @humanspeak/svelte-scoped-props | Scoped class props for Svelte |
MIT © Humanspeak, Inc.
Made with ❤️ by Humanspeak
string |
'Chat messages' |
| Accessible label for the scrollable viewport region |
testId | string | - | Base test ID for data-testid attributes |