refactor: change TTD persistence to iDB (#10662)
refactor: change ttd persistence to iDB
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user