Skip to content

Chat with Your Own Storage

read as .md

You own the UI, you own the storage. ggui gives you the streaming protocol (useInvoke) plus pure helpers for persistence shape (@ggui-ai/react/chat-helpers). Where messages live, how threads are indexed, what your composer looks like — that’s yours.

Pick this pattern when at least one holds:

  • You already have a persistence layer (Postgres, Firestore, IndexedDB, your Redux store) and don’t want a second one.
  • You need full control of the message schema — custom attachments, per-message ACLs, server-side fan-out.
  • You’re integrating ggui into an existing chat surface, not building a new one.

If none apply and you just want “a chat UI that works”, reach for useChatThread in @ggui-ai/react/chat-thread — same flow behind a single hook with a pluggable MessageStorageAdapter and a ChatThreadProvider.

The file below is the complete working integration — streaming, persistence, card rendering, send — in ~60 lines.

import { useEffect } from "react";
import { GguiProvider, useInvoke } from "@ggui-ai/react";
import {
useRafThrottled,
invokeMessageToContentGroups,
extractRenderFromToolResult,
type ContentGroup,
} from "@ggui-ai/react/chat-helpers";
// Replace with any storage: localStorage, fetch, IndexedDB, Firestore, …
const store: Array<{ threadId: string; group: ContentGroup }> = [];
function persist(threadId: string, messages: ReturnType<typeof useInvoke>["messages"]) {
const seen = new Set(store.filter((e) => e.threadId === threadId).map((e) => e.group.key));
for (const msg of messages) {
for (const group of invokeMessageToContentGroups(msg)) {
if (!seen.has(group.key)) store.push({ threadId, group });
}
}
}
function Chat({ threadId, endpointUrl }: { threadId: string; endpointUrl: string }) {
const { messages, send, isStreaming } = useInvoke({ endpointUrl });
const throttled = useRafThrottled(messages);
useEffect(() => {
persist(threadId, messages);
}, [threadId, messages]);
return (
<div>
{throttled.map((m) => (
<div key={m.id}>
<strong>{m.role}: </strong>
{m.content.map((b, i) => {
if (b.type === "text") return <p key={i}>{b.text}</p>;
if (b.type === "tool_result") {
const render = extractRenderFromToolResult(b);
return <pre key={i}>{JSON.stringify(render, null, 2)}</pre>;
}
return null;
})}
</div>
))}
<button
disabled={isStreaming}
onClick={() => send("hello", { clientMessageId: crypto.randomUUID() })}
>
Send hello
</button>
</div>
);
}
export default function App() {
return (
<GguiProvider appId="demo">
<Chat threadId="t1" endpointUrl="http://127.0.0.1:6781" />
</GguiProvider>
);
}

invokeMessageToContentGroups(message) splits a finalized invoke message into ContentGroups — the durable unit you persist:

interface ContentGroup {
key: string; // `${message.id}-${startBlockIdx}` — see invariant below
kind: "text" | "card" | "other";
authorRole: "user" | "agent";
blocks: ContentBlock[]; // a contiguous run of text, or a tool_use + tool_result pair
cardSnapshot: unknown | null; // frozen GguiSession for kind="card"
textPreview: string; // ~160-char preview for chat-list tiles
}

The key invariant. key is deterministic from message.id plus the block index where the group starts. Two consequences:

  1. Idempotency. Re-persisting the same group with the same key is a no-op. If your storage uses key as primary key (recommended), the persist() loop above can run after every token delta without producing duplicates.
  2. Streaming messages are excluded. A message whose isStreaming is true returns [] — groups appear only after the turn finalizes. That’s why persist() doesn’t need a separate “on end_turn” callback.

On thread reopen, rebuild the ConversationMessage[] your store remembers and seed the hook via initialMessages. contentGroupsToConversationMessages collapses groups sharing the same message.id prefix back into one message:

import { contentGroupsToConversationMessages } from "@ggui-ai/react/chat-helpers";
function useSeededInvoke(threadId: string, endpointUrl: string) {
// Resolve before render — useInvoke captures initialMessages on mount.
const groups = store.filter((e) => e.threadId === threadId).map((e) => e.group);
const seed = contentGroupsToConversationMessages(groups);
return useInvoke({ endpointUrl, initialMessages: seed });
}

initialMessages is a seed on mount. Changing it on re-render does not reset hook state — intentional; the hook owns the conversation after mount. To switch threads, unmount the <Chat> subtree (set key={threadId}) and let the new instance seed from that thread’s store.

useInvoke accepts an optional clientMessageId the caller controls:

send("hello", { clientMessageId: crypto.randomUUID() });

The rendered user message’s id becomes that value, which buys:

  • Retry without duplicates. Retrying the same send after a network failure produces the same clientMessageId → same ContentGroup.key → the outbox is idempotent by construction.
  • Cross-device continuity. Persist user messages optimistically on the sending device; when the agent turn later replays on another device, the same clientMessageId collapses both into one thread entry.

Without clientMessageId, useInvoke falls back to a random user_<hex> id — fine for ephemeral chats, wrong for durable storage.

The helpers stop at shape. You still own:

  • Transport to storage. store.push(...) above is an in-memory array for brevity. Replace it with fetch('/persist'), an IndexedDB write, a Firestore batch — whatever fits your stack.
  • Thread indexing. ContentGroup.textPreview is the building block for chat-list tiles; wiring it into a sidebar is yours.
  • Reconnect + resume. useInvoke does not replay an interrupted stream. If the page reloads mid-turn, the in-flight assistant message is lost — only finalized groups persist. useChatThread (next segment up) closes this gap.

When those boundaries start to hurt, reach for useChatThread: same primitives, plus a MessageStorageAdapter interface, a ChatThreadProvider, the outbox, seed-on-reopen wiring, and the optimistic-send UX.