refactor: change TTD persistence to iDB (#10662)

refactor: change ttd persistence to iDB
This commit is contained in:
David Luzar
2026-01-16 15:10:00 +01:00
committed by GitHub
parent 24a6941861
commit 5a73b9a363
9 changed files with 256 additions and 103 deletions
+1
View File
@@ -46,6 +46,7 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+3
View File
@@ -11,6 +11,8 @@ import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({
excalidrawAPI,
}: {
@@ -116,6 +118,7 @@ export const AIComponents = ({
return result;
}}
persistenceAdapter={TTDIndexedDBAdapter}
/>
</>
);
+51
View File
@@ -0,0 +1,51 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
@@ -15,13 +15,18 @@ import { TTDDialogTab } from "./TTDDialogTab";
import "./TTDDialog.scss";
import type { MermaidToExcalidrawLibProps, TTTDDialog } from "./types";
import type {
MermaidToExcalidrawLibProps,
TTDPersistenceAdapter,
TTTDDialog,
} from "./types";
export const TTDDialog = (
props:
| {
onTextSubmit: TTTDDialog.onTextSubmit;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}
| { __fallback: true },
) => {
@@ -50,6 +55,7 @@ const TTDDialogBase = withInternalFallback(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}
| { __fallback: true }
)) => {
@@ -105,6 +111,7 @@ const TTDDialogBase = withInternalFallback(
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={rest.onTextSubmit}
renderWarning={rest.renderWarning}
persistenceAdapter={rest.persistenceAdapter}
/>
</TTDDialogTab>
)}
@@ -25,18 +25,25 @@ import { TTDPreviewPanel } from "./TTDPreviewPanel";
import { getLastAssistantMessage } from "./utils/chat";
import type { BinaryFiles } from "../../types";
import type { MermaidToExcalidrawLibProps, TChat, TTTDDialog } from "./types";
import type {
MermaidToExcalidrawLibProps,
TChat,
TTDPersistenceAdapter,
TTTDDialog,
} from "./types";
const TextToDiagramContent = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWarning,
persistenceAdapter,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit: (
props: TTTDDialog.OnTextSubmitProps,
) => Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
@@ -46,7 +53,7 @@ const TextToDiagramContent = ({
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const showPreview = useAtomValue(showPreviewAtom);
const { savedChats } = useTTDChatStorage();
const { savedChats } = useTTDChatStorage({ persistenceAdapter });
const lastAssistantMessage = getLastAssistantMessage(chatHistory);
@@ -68,7 +75,7 @@ const TextToDiagramContent = ({
handleNewChat,
handleMenuToggle,
handleMenuClose,
} = useChatManagement();
} = useChatManagement({ persistenceAdapter });
const onViewAsMermaid = () => {
if (typeof lastAssistantMessage?.content === "string") {
@@ -231,18 +238,21 @@ export const TextToDiagram = ({
mermaidToExcalidrawLib,
onTextSubmit,
renderWarning,
persistenceAdapter,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
onTextSubmit(
props: TTTDDialog.OnTextSubmitProps,
): Promise<TTTDDialog.OnTextSubmitRetValue>;
renderWarning?: TTTDDialog.renderWarning;
persistenceAdapter: TTDPersistenceAdapter;
}) => {
return (
<TextToDiagramContent
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
onTextSubmit={onTextSubmit}
renderWarning={renderWarning}
persistenceAdapter={persistenceAdapter}
/>
);
};
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { useAtom, useSetAtom } from "../../../editor-jotai";
@@ -8,81 +8,100 @@ import { useTTDChatStorage } from "../useTTDChatStorage";
import { getLastAssistantMessage } from "../utils/chat";
import type { SavedChat } from "../types";
import type { SavedChat, TTDPersistenceAdapter } from "../types";
export const useChatManagement = () => {
interface UseChatManagementProps {
persistenceAdapter: TTDPersistenceAdapter;
}
export const useChatManagement = ({
persistenceAdapter,
}: UseChatManagementProps) => {
const setError = useSetAtom(errorAtom);
const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage();
const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage({
persistenceAdapter,
});
const resetChatState = () => {
const newSessionId = createNewChatId();
const applyChatToState = useCallback(
(chat: SavedChat) => {
const restoredMessages = chat.messages.map((msg) => ({
...msg,
timestamp:
msg.timestamp instanceof Date
? msg.timestamp
: new Date(msg.timestamp),
}));
const history = {
id: chat.id,
messages: restoredMessages,
currentPrompt: "",
};
const lastAssistantMsg = getLastAssistantMessage(history);
setError(
lastAssistantMsg?.error ? new Error(lastAssistantMsg?.error) : null,
);
setChatHistory(history);
},
[setError, setChatHistory],
);
const resetChatState = useCallback(async () => {
const newSessionId = await createNewChatId();
setChatHistory({
id: newSessionId,
messages: [],
currentPrompt: "",
});
setError(null);
};
}, [createNewChatId, setChatHistory, setError]);
const applyChatToState = (chat: SavedChat) => {
const restoredMessages = chat.messages.map((msg) => ({
...msg,
timestamp:
msg.timestamp instanceof Date ? msg.timestamp : new Date(msg.timestamp),
}));
const onRestoreChat = useCallback(
(chat: SavedChat) => {
const restoredChat = restoreChat(chat);
applyChatToState(restoredChat);
const history = {
id: chat.id,
messages: restoredMessages,
currentPrompt: "",
};
setIsMenuOpen(false);
},
[restoreChat, applyChatToState],
);
const lastAssistantMsg = getLastAssistantMessage(history);
const handleDeleteChat = useCallback(
async (chatId: string, event: React.MouseEvent) => {
event.stopPropagation();
setError(
lastAssistantMsg?.error ? new Error(lastAssistantMsg?.error) : null,
);
setChatHistory(history);
};
const isDeletingActiveChat = chatId === chatHistory.id;
const updatedChats = await deleteChat(chatId);
const onRestoreChat = (chat: SavedChat) => {
const restoredChat = restoreChat(chat);
applyChatToState(restoredChat);
setIsMenuOpen(false);
};
const handleDeleteChat = (chatId: string, event: React.MouseEvent) => {
event.stopPropagation();
const isDeletingActiveChat = chatId === chatHistory.id;
const updatedChats = deleteChat(chatId);
if (isDeletingActiveChat) {
if (updatedChats.length > 0) {
const nextChat = updatedChats[0];
applyChatToState(nextChat);
} else {
resetChatState();
if (isDeletingActiveChat) {
if (updatedChats.length > 0) {
const nextChat = updatedChats[0];
applyChatToState(nextChat);
} else {
await resetChatState();
}
}
}
};
},
[chatHistory.id, deleteChat, applyChatToState, resetChatState],
);
const handleNewChat = () => {
resetChatState();
const handleNewChat = useCallback(async () => {
await resetChatState();
setIsMenuOpen(false);
};
}, [resetChatState]);
const handleMenuToggle = () => {
const handleMenuToggle = useCallback(() => {
setIsMenuOpen((prev) => !prev);
};
}, []);
const handleMenuClose = () => {
const handleMenuClose = useCallback(() => {
setIsMenuOpen(false);
};
}, []);
return {
isMenuOpen,
@@ -53,6 +53,24 @@ export interface SavedChat {
timestamp: number;
}
export type SavedChats = SavedChat[];
/**
* Interface for TTD chat persistence. Preferably should be stable
* (e.g. static class/singleton)
*/
export interface TTDPersistenceAdapter {
/**
* Load saved chats from storage.
*/
loadChats(): Promise<SavedChats>;
/**
* Save chats to storage.
*/
saveChats(chats: SavedChats): Promise<void>;
}
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
@@ -1,44 +1,24 @@
import { useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { randomId } from "@excalidraw/common";
import { atom, useAtom } from "../../editor-jotai";
import { chatHistoryAtom } from "./TTDContext";
import type { SavedChat } from "./types";
import type { SavedChat, SavedChats, TTDPersistenceAdapter } from "./types";
const TTD_CHATS_STORAGE_KEY = "excalidraw-ttd-chats";
interface UseTTDChatStorageProps {
persistenceAdapter: TTDPersistenceAdapter;
}
interface UseTTDChatStorageReturn {
savedChats: SavedChats;
saveCurrentChat: () => void;
deleteChat: (chatId: string) => SavedChats;
saveCurrentChat: () => Promise<void>;
deleteChat: (chatId: string) => Promise<SavedChats>;
restoreChat: (chat: SavedChat) => SavedChat;
createNewChatId: () => string;
createNewChatId: () => Promise<string>;
}
type SavedChats = SavedChat[];
const saveChatsToStorage = (chats: SavedChats) => {
try {
window.localStorage.setItem(TTD_CHATS_STORAGE_KEY, JSON.stringify(chats));
} catch (error: any) {
console.warn(`Failed to save chats to localStorage: ${error.message}`);
}
};
const loadChatsFromStorage = (): SavedChats => {
try {
const data = window.localStorage.getItem(TTD_CHATS_STORAGE_KEY);
if (data) {
return JSON.parse(data) as SavedChats;
}
} catch (error: any) {
console.warn(`Failed to load chats from localStorage: ${error.message}`);
}
return [];
};
const generateChatTitle = (firstMessage: string): string => {
const trimmed = firstMessage.trim();
if (trimmed.length <= 50) {
@@ -47,17 +27,60 @@ const generateChatTitle = (firstMessage: string): string => {
return `${trimmed.substring(0, 47)}...`;
};
// Shared atom for saved chats - initialized once from localStorage
export const savedChatsAtom = atom<SavedChats>(loadChatsFromStorage());
// Shared atom for saved chats - starts empty, populated via onLoadChats
export const savedChatsAtom = atom<SavedChats>([]);
export const isLoadingChatsAtom = atom<boolean>(false);
export const chatsLoadedAtom = atom<boolean>(false);
export const useTTDChatStorage = (): UseTTDChatStorageReturn => {
export const useTTDChatStorage = ({
persistenceAdapter,
}: UseTTDChatStorageProps): UseTTDChatStorageReturn => {
const [chatHistory] = useAtom(chatHistoryAtom);
const [savedChats, setSavedChats] = useAtom(savedChatsAtom);
const [isLoading, setIsLoading] = useAtom(isLoadingChatsAtom);
const [chatsLoaded, setChatsLoaded] = useAtom(chatsLoadedAtom);
// Ref to track latest savedChats for async operations
const savedChatsRef = useRef(savedChats);
savedChatsRef.current = savedChats;
const lastMessageInHistory =
chatHistory?.messages[chatHistory?.messages.length - 1];
const saveCurrentChat = () => {
// Load chats on-demand
const loadChats = useCallback(async () => {
if (chatsLoaded || isLoading) {
return;
}
setIsLoading(true);
try {
const chats = await persistenceAdapter.loadChats();
setSavedChats(chats);
setChatsLoaded(true);
} catch (error) {
console.warn("Failed to load chats:", error);
setSavedChats([]);
setChatsLoaded(true);
} finally {
setIsLoading(false);
}
}, [
chatsLoaded,
isLoading,
setSavedChats,
setIsLoading,
setChatsLoaded,
persistenceAdapter,
]);
// INITIAL LOAD
useEffect(() => {
loadChats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const saveCurrentChat = useCallback(async () => {
if (chatHistory.messages.length === 0) {
return;
}
@@ -71,7 +94,7 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => {
const title = generateChatTitle(firstUserMessage.content);
const currentSavedChats = loadChatsFromStorage();
const currentSavedChats = savedChatsRef.current;
const existingChat = currentSavedChats.find(
(chat) => chat.id === chatHistory.id,
);
@@ -111,9 +134,15 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => {
.slice(0, 10);
setSavedChats(updatedChats);
saveChatsToStorage(updatedChats);
};
try {
await persistenceAdapter.saveChats(updatedChats);
} catch (error) {
console.warn("Failed to save chats:", error);
}
}, [chatHistory, setSavedChats, persistenceAdapter]);
// Auto-save when generation completes
useEffect(() => {
if (!lastMessageInHistory?.isGenerating) {
saveCurrentChat();
@@ -125,23 +154,33 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => {
lastMessageInHistory?.isGenerating,
]);
const deleteChat = (chatId: string): SavedChats => {
const updatedChats = savedChats.filter((chat) => chat.id !== chatId);
setSavedChats(updatedChats);
saveChatsToStorage(updatedChats);
const deleteChat = useCallback(
async (chatId: string): Promise<SavedChats> => {
const updatedChats = savedChatsRef.current.filter(
(chat) => chat.id !== chatId,
);
setSavedChats(updatedChats);
return updatedChats;
};
try {
await persistenceAdapter.saveChats(updatedChats);
} catch (error) {
console.warn("Failed to save after delete:", error);
}
const restoreChat = (chat: SavedChat): SavedChat => {
saveCurrentChat();
return updatedChats;
},
[setSavedChats, persistenceAdapter],
);
const restoreChat = useCallback((chat: SavedChat): SavedChat => {
// Save is handled by the caller after state update
return chat;
};
}, []);
const createNewChatId = (): string => {
saveCurrentChat();
const createNewChatId = useCallback(async (): Promise<string> => {
await saveCurrentChat();
return randomId();
};
}, [saveCurrentChat]);
return {
savedChats,
+5
View File
@@ -290,6 +290,11 @@ export { DefaultSidebar } from "./components/DefaultSidebar";
export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
export { TTDStreamFetch } from "./components/TTDDialog/utils/TTDStreamFetch";
export type {
TTDPersistenceAdapter,
SavedChat,
SavedChats,
} from "./components/TTDDialog/types";
export { zoomToFitBounds } from "./actions/actionCanvas";
export {