Skip to content

Commit 4788d7f

Browse files
committed
feat(conversations): opt-in browser-storage mode for conversations
Add an optional mode where conversations are stored in IndexedDB on the user's device instead of MongoDB. Gated by a new env var PUBLIC_ENABLE_LOCAL_CONVERSATIONS (default false) and a user setting useLocalConversations — production behavior is unchanged unless both are enabled. When the user opts in, the homepage routes new chats to /local/<uuid>, backed by IndexedDB on the client and a new stateless streaming endpoint POST /api/v2/chat/stream on the server. The existing /conversation/[id] flow and persistence layer are untouched. https://claude.ai/code/session_01GSjF6LBivXS8ZryKsWDzjc
1 parent a867e2b commit 4788d7f

16 files changed

Lines changed: 918 additions & 30 deletions

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ PUBLIC_SHARE_PREFIX=
2828
PUBLIC_GOOGLE_ANALYTICS_ID=
2929
PUBLIC_PLAUSIBLE_SCRIPT_URL=
3030
PUBLIC_APPLE_APP_ID=
31+
# Opt-in feature: expose a user setting to store conversations in the browser
32+
# (IndexedDB) instead of the server. Default off — production behavior unchanged.
33+
PUBLIC_ENABLE_LOCAL_CONVERSATIONS=false
3134

3235
COUPLE_SESSION_WITH_COOKIE_NAME=
3336
# when OPEN_ID is configured, users are required to login after the welcome modal

src/lib/components/NavConversationItem.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222
2323
let { conv, readOnly, ondeleteConversation, oneditConversationTitle }: Props = $props();
2424
25+
// Conversations stored in IndexedDB use a "local:<uuid>" id and live at
26+
// /local/<uuid> instead of /conversation/<id>.
27+
const idStr = $derived(conv.id.toString());
28+
const isLocal = $derived(idStr.startsWith("local:"));
29+
const routeId = $derived(isLocal ? idStr.slice("local:".length) : idStr);
30+
const href = $derived(isLocal ? `${base}/local/${routeId}` : `${base}/conversation/${routeId}`);
31+
const isActive = $derived(routeId === page.params.id);
32+
2533
let deleteOpen = $state(false);
2634
let renameOpen = $state(false);
2735
let isMenuOpen = $state(false);
@@ -57,7 +65,7 @@
5765

5866
<div
5967
class="group flex h-8 flex-none items-center gap-1.5 rounded-lg pl-2 pr-1.5 text-base text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 max-sm:h-10 sm:text-sm
60-
{conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
68+
{isActive ? 'bg-gray-100 dark:bg-gray-700' : ''}"
6169
>
6270
{#if inlineEditing}
6371
<input
@@ -81,7 +89,7 @@
8189
<a
8290
data-sveltekit-noscroll
8391
data-sveltekit-preload-data="tap"
84-
href="{base}/conversation/{conv.id}"
92+
{href}
8593
class="min-w-0 flex-1 truncate py-2 first-letter:uppercase"
8694
onclick={(e) => {
8795
if (e.detail >= 2) {

src/lib/server/api/__tests__/user.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe("GET /api/v2/user/settings", () => {
9191
toolsOverrides: {},
9292
hidePromptExamples: {},
9393
providerOverrides: {},
94+
useLocalConversations: false,
9495
welcomeModalSeenAt: new Date("2024-01-01"),
9596
createdAt: new Date(),
9697
updatedAt: new Date(),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Local Conversations Store
3+
*
4+
* Stores conversations in IndexedDB on the user's device instead of MongoDB.
5+
* Enabled only when the operator sets PUBLIC_ENABLE_LOCAL_CONVERSATIONS=true
6+
* AND the user opts in via the `useLocalConversations` setting.
7+
*
8+
* Shape mirrors `Conversation` but `_id` is a string uuid (no MongoDB ObjectId).
9+
*/
10+
11+
import { writable } from "svelte/store";
12+
import { browser } from "$app/environment";
13+
import { env as publicEnv } from "$env/dynamic/public";
14+
import { base } from "$app/paths";
15+
import type { Message } from "$lib/types/Message";
16+
17+
export interface LocalConversation {
18+
_id: string;
19+
model: string;
20+
title: string;
21+
rootMessageId?: Message["id"];
22+
messages: Message[];
23+
preprompt?: string;
24+
createdAt: Date;
25+
updatedAt: Date;
26+
}
27+
28+
function toKeyPart(s: string | undefined): string {
29+
return (s || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
30+
}
31+
32+
const appLabel = toKeyPart(publicEnv.PUBLIC_APP_ASSETS || publicEnv.PUBLIC_APP_NAME);
33+
const baseLabel = toKeyPart(typeof base === "string" ? base : "");
34+
const DB_NAME = `${appLabel || baseLabel || "app"}:local-conversations`;
35+
const STORE_NAME = "conversations";
36+
const DB_VERSION = 1;
37+
38+
let dbPromise: Promise<IDBDatabase> | null = null;
39+
40+
function openDB(): Promise<IDBDatabase> {
41+
if (!browser) return Promise.reject(new Error("IndexedDB only available in browser"));
42+
if (dbPromise) return dbPromise;
43+
dbPromise = new Promise((resolve, reject) => {
44+
const req = indexedDB.open(DB_NAME, DB_VERSION);
45+
req.onupgradeneeded = () => {
46+
const db = req.result;
47+
if (!db.objectStoreNames.contains(STORE_NAME)) {
48+
db.createObjectStore(STORE_NAME, { keyPath: "_id" });
49+
}
50+
};
51+
req.onsuccess = () => resolve(req.result);
52+
req.onerror = () => reject(req.error);
53+
});
54+
return dbPromise;
55+
}
56+
57+
async function tx<T>(
58+
mode: IDBTransactionMode,
59+
fn: (store: IDBObjectStore) => IDBRequest<T>
60+
): Promise<T> {
61+
const db = await openDB();
62+
return new Promise((resolve, reject) => {
63+
const transaction = db.transaction(STORE_NAME, mode);
64+
const store = transaction.objectStore(STORE_NAME);
65+
const req = fn(store);
66+
req.onsuccess = () => resolve(req.result);
67+
req.onerror = () => reject(req.error);
68+
});
69+
}
70+
71+
/** Reactive store of local conversations, sorted by updatedAt descending. */
72+
export const localConversations = writable<LocalConversation[]>([]);
73+
74+
async function refreshStore() {
75+
if (!browser) return;
76+
try {
77+
const all = await list();
78+
localConversations.set(all);
79+
} catch (err) {
80+
console.error("Failed to load local conversations:", err);
81+
}
82+
}
83+
84+
export async function list(): Promise<LocalConversation[]> {
85+
const all = await tx<LocalConversation[]>("readonly", (store) => store.getAll());
86+
return all.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
87+
}
88+
89+
export async function get(id: string): Promise<LocalConversation | undefined> {
90+
const result = await tx<LocalConversation | undefined>("readonly", (store) => store.get(id));
91+
return result;
92+
}
93+
94+
export async function put(conv: LocalConversation): Promise<void> {
95+
await tx("readwrite", (store) => store.put(conv));
96+
await refreshStore();
97+
}
98+
99+
export async function remove(id: string): Promise<void> {
100+
await tx("readwrite", (store) => store.delete(id));
101+
await refreshStore();
102+
}
103+
104+
export async function clear(): Promise<void> {
105+
await tx("readwrite", (store) => store.clear());
106+
await refreshStore();
107+
}
108+
109+
// Initialize the reactive store on module load (browser only).
110+
if (browser) {
111+
refreshStore();
112+
}

src/lib/stores/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type SettingsStore = {
2424
directPaste: boolean;
2525
hapticsEnabled: boolean;
2626
billingOrganization?: string;
27+
useLocalConversations: boolean;
2728
};
2829

2930
type SettingsStoreWritable = Writable<SettingsStore> & {

src/lib/types/Settings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ export interface Settings extends Timestamps {
8080
* Stores the org's preferred_username. If empty/undefined, bills to personal account.
8181
*/
8282
billingOrganization?: string;
83+
84+
/**
85+
* When enabled, new conversations are stored in the browser (IndexedDB)
86+
* instead of the server. Only effective if the operator has enabled the
87+
* feature via PUBLIC_ENABLE_LOCAL_CONVERSATIONS.
88+
*/
89+
useLocalConversations: boolean;
8390
}
8491

8592
export type SettingsEditable = Omit<Settings, "welcomeModalSeenAt" | "createdAt" | "updatedAt">;
@@ -98,4 +105,5 @@ export const DEFAULT_SETTINGS = {
98105
streamingMode: "smooth",
99106
directPaste: false,
100107
hapticsEnabled: true,
108+
useLocalConversations: false,
101109
} satisfies SettingsEditable;

src/lib/utils/messageUpdates.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ type MessageUpdateRequestOptions = {
2626
// User's IANA timezone (e.g. "America/New_York")
2727
timezone?: string;
2828
streamingMode?: StreamingMode;
29+
/**
30+
* When set, POST a JSON body to `${base}${statelessEndpoint}` instead of
31+
* the default form-data POST to `${base}/conversation/${conversationId}`.
32+
* Used by the local-conversations mode which sends full history per request.
33+
*/
34+
statelessEndpoint?: string;
35+
statelessRequest?: {
36+
model: string;
37+
messages: Array<{ from: "user" | "assistant" | "system"; content: string }>;
38+
preprompt?: string;
39+
};
2940
};
3041

3142
type ChunkDetector = (buffer: string) => string | null;
@@ -50,32 +61,47 @@ export async function fetchMessageUpdates(
5061
const abortController = new AbortController();
5162
abortSignal.addEventListener("abort", () => abortController.abort());
5263

53-
const form = new FormData();
54-
55-
const optsJSON = JSON.stringify({
56-
inputs: opts.inputs,
57-
id: opts.messageId,
58-
is_retry: opts.isRetry,
59-
is_continue: Boolean(opts.isContinue),
60-
// Will be ignored server-side if unsupported
61-
selectedMcpServerNames: opts.selectedMcpServerNames,
62-
selectedMcpServers: opts.selectedMcpServers,
63-
timezone: opts.timezone,
64-
});
64+
let response: Response;
65+
if (opts.statelessEndpoint && opts.statelessRequest) {
66+
response = await fetch(`${opts.base}${opts.statelessEndpoint}`, {
67+
method: "POST",
68+
headers: { "Content-Type": "application/json" },
69+
body: JSON.stringify({
70+
...opts.statelessRequest,
71+
selectedMcpServerNames: opts.selectedMcpServerNames,
72+
selectedMcpServers: opts.selectedMcpServers,
73+
timezone: opts.timezone,
74+
}),
75+
signal: abortController.signal,
76+
});
77+
} else {
78+
const form = new FormData();
79+
80+
const optsJSON = JSON.stringify({
81+
inputs: opts.inputs,
82+
id: opts.messageId,
83+
is_retry: opts.isRetry,
84+
is_continue: Boolean(opts.isContinue),
85+
// Will be ignored server-side if unsupported
86+
selectedMcpServerNames: opts.selectedMcpServerNames,
87+
selectedMcpServers: opts.selectedMcpServers,
88+
timezone: opts.timezone,
89+
});
6590

66-
opts.files?.forEach((file) => {
67-
const name = file.type + ";" + file.name;
91+
opts.files?.forEach((file) => {
92+
const name = file.type + ";" + file.name;
6893

69-
form.append("files", new File([file.value], name, { type: file.mime }));
70-
});
94+
form.append("files", new File([file.value], name, { type: file.mime }));
95+
});
7196

72-
form.append("data", optsJSON);
97+
form.append("data", optsJSON);
7398

74-
const response = await fetch(`${opts.base}/conversation/${conversationId}`, {
75-
method: "POST",
76-
body: form,
77-
signal: abortController.signal,
78-
});
99+
response = await fetch(`${opts.base}/conversation/${conversationId}`, {
100+
method: "POST",
101+
body: form,
102+
signal: abortController.signal,
103+
});
104+
}
79105

80106
if (!response.ok) {
81107
const errorMessage = await response

src/routes/+layout.svelte

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import { createSettingsStore } from "$lib/stores/settings";
1111
import { loading } from "$lib/stores/loading";
1212
import { setHapticsEnabled } from "$lib/utils/haptics";
13+
import { localConversations, remove as removeLocalConv } from "$lib/stores/localConversations";
14+
import type { ConvSidebar } from "$lib/types/ConvSidebar";
1315
1416
import Toast from "$lib/components/Toast.svelte";
1517
import NavMenu from "$lib/components/NavMenu.svelte";
@@ -33,9 +35,27 @@
3335
const publicConfig = data.publicConfig;
3436
const client = useAPIClient();
3537
36-
let conversations = $state(data.conversations);
38+
let serverConversations = $state(data.conversations);
3739
$effect(() => {
38-
data.conversations && untrack(() => (conversations = data.conversations));
40+
data.conversations && untrack(() => (serverConversations = data.conversations));
41+
});
42+
43+
let localModeEnabled = $derived(
44+
publicConfig.PUBLIC_ENABLE_LOCAL_CONVERSATIONS === "true" &&
45+
Boolean(data.settings?.useLocalConversations)
46+
);
47+
48+
let conversations = $derived.by<ConvSidebar[]>(() => {
49+
if (!localModeEnabled) return serverConversations;
50+
const local: ConvSidebar[] = $localConversations.map((c) => ({
51+
id: `local:${c._id}`,
52+
title: c.title,
53+
model: c.model,
54+
updatedAt: c.updatedAt,
55+
}));
56+
return [...local, ...serverConversations].sort(
57+
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
58+
);
3959
});
4060
4161
let isNavCollapsed = $state(false);
@@ -66,12 +86,25 @@
6686
);
6787
6888
async function deleteConversation(id: string) {
89+
if (typeof id === "string" && id.startsWith("local:")) {
90+
const localId = id.slice("local:".length);
91+
try {
92+
await removeLocalConv(localId);
93+
if (page.params.id === localId) {
94+
await goto(`${base}/`, { invalidateAll: true });
95+
}
96+
} catch (err) {
97+
console.error(err);
98+
$error = String(err);
99+
}
100+
return;
101+
}
69102
client
70103
.conversations({ id })
71104
.delete()
72105
.then(handleResponse)
73106
.then(async () => {
74-
conversations = conversations.filter((conv) => conv.id !== id);
107+
serverConversations = serverConversations.filter((conv) => conv.id !== id);
75108
76109
if (page.params.id === id) {
77110
await goto(`${base}/`, { invalidateAll: true });
@@ -84,12 +117,18 @@
84117
}
85118
86119
async function editConversationTitle(id: string, title: string) {
120+
if (typeof id === "string" && id.startsWith("local:")) {
121+
// Editing titles of local conversations is not supported in v1.
122+
return;
123+
}
87124
client
88125
.conversations({ id })
89126
.patch({ title })
90127
.then(handleResponse)
91128
.then(async () => {
92-
conversations = conversations.map((conv) => (conv.id === id ? { ...conv, title } : conv));
129+
serverConversations = serverConversations.map((conv) =>
130+
conv.id === id ? { ...conv, title } : conv
131+
);
93132
})
94133
.catch((err) => {
95134
console.error(err);
@@ -112,10 +151,13 @@
112151
113152
$effect(() => {
114153
if ($titleUpdate) {
115-
const convIdx = conversations.findIndex(({ id }) => id === $titleUpdate?.convId);
154+
// Local convs sync their title through the localConversations store, so
155+
// only mutate serverConversations here.
156+
const convIdx = serverConversations.findIndex(({ id }) => id === $titleUpdate?.convId);
116157
117158
if (convIdx != -1) {
118-
conversations[convIdx].title = $titleUpdate?.title ?? conversations[convIdx].title;
159+
serverConversations[convIdx].title =
160+
$titleUpdate?.title ?? serverConversations[convIdx].title;
119161
}
120162
121163
$titleUpdate = null;

src/routes/+layout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface SettingsResponse {
3737
reasoningEffortOverrides: Record<string, "low" | "medium" | "high">;
3838
reasoningOverrides: Record<string, boolean>;
3939
billingOrganization?: string;
40+
useLocalConversations: boolean;
4041
}
4142

4243
export const load = async ({ depends, fetch, url }) => {

0 commit comments

Comments
 (0)