Skip to content

Commit b3ad3d1

Browse files
committed
feat(ai-chat): persist active conversation locally
1 parent c3501ff commit b3ad3d1

File tree

3 files changed

+249
-8
lines changed

3 files changed

+249
-8
lines changed

src/components/ai/AiChat.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ import {
5151
ChartTooltipContent
5252
} from "@flanksource-ui/components/ui/chart";
5353
import { formatTick, parseTimestamp } from "@flanksource-ui/lib/timeseries";
54+
import {
55+
clearActiveAIConversation,
56+
saveActiveAIConversation
57+
} from "@flanksource-ui/lib/ai-chat-history";
5458
import { cn } from "@flanksource-ui/lib/utils";
5559
import type { FileUIPart, ReasoningUIPart, UIMessage } from "ai";
5660
import { getToolName, isToolUIPart } from "ai";
@@ -214,6 +218,14 @@ export function AIChat({
214218
id
215219
});
216220

221+
useEffect(() => {
222+
if (!chat.id || messages.length === 0) {
223+
return;
224+
}
225+
226+
void saveActiveAIConversation(chat.id, messages);
227+
}, [chat.id, messages]);
228+
217229
// Auto-send when chat mounts with a pre-seeded user message (e.g. from setChatMessages).
218230
const hasSentOnMount = useRef(false);
219231
useEffect(() => {
@@ -265,11 +277,17 @@ export function AIChat({
265277
);
266278

267279
const handleNewChat = useCallback(() => {
268-
setMessages([]);
280+
void clearActiveAIConversation();
281+
282+
if (onNewChat) {
283+
onNewChat();
284+
} else {
285+
setMessages([]);
286+
}
287+
269288
if (error) {
270289
clearError();
271290
}
272-
onNewChat?.();
273291
}, [clearError, error, onNewChat, setMessages]);
274292

275293
const handleSuggestionClick = useCallback(

src/components/ai/AiChatPopover.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { Chat, UIMessage } from "@ai-sdk/react";
1111
import { Resizable } from "re-resizable";
1212
import { AIChat } from "@flanksource-ui/components/ai/AiChat";
13+
import { loadActiveAIConversation } from "@flanksource-ui/lib/ai-chat-history";
1314
import {
1415
Popover,
1516
PopoverContent,
@@ -20,6 +21,7 @@ type AiChatPopoverContextValue = {
2021
open: boolean;
2122
setOpen: (open: boolean) => void;
2223
chat: Chat<UIMessage>;
24+
chatVersion: number;
2325
resetChat: () => void;
2426
setChatMessages: (messages: UIMessage[]) => void;
2527
quickPrompts?: string[];
@@ -43,9 +45,41 @@ export function AiChatPopoverProvider({
4345
}: AiChatPopoverProviderProps) {
4446
const [open, setOpenState] = useState(initialOpen);
4547
const [chat, setChat] = useState(() => new Chat<UIMessage>({ id: chatId }));
48+
const [chatVersion, setChatVersion] = useState(0);
4649
const [quickPrompts, setQuickPrompts] = useState<string[] | undefined>(
4750
undefined
4851
);
52+
53+
const replaceChat = useCallback((nextChat: Chat<UIMessage>) => {
54+
setChat(nextChat);
55+
setChatVersion((version) => version + 1);
56+
}, []);
57+
58+
useEffect(() => {
59+
let cancelled = false;
60+
61+
const hydrateActiveConversation = async () => {
62+
const activeConversation = await loadActiveAIConversation();
63+
64+
if (cancelled || !activeConversation) {
65+
return;
66+
}
67+
68+
replaceChat(
69+
new Chat<UIMessage>({
70+
id: activeConversation.conversationId,
71+
messages: activeConversation.messages
72+
})
73+
);
74+
};
75+
76+
void hydrateActiveConversation();
77+
78+
return () => {
79+
cancelled = true;
80+
};
81+
}, [replaceChat]);
82+
4983
const setOpen = useCallback(
5084
(open: boolean) => {
5185
setOpenState(open);
@@ -58,29 +92,30 @@ export function AiChatPopoverProvider({
5892

5993
const resetChat = useCallback(() => {
6094
const newId = `${chatId}-${Date.now()}`;
61-
setChat(new Chat<UIMessage>({ id: newId }));
95+
replaceChat(new Chat<UIMessage>({ id: newId }));
6296
setQuickPrompts(undefined);
63-
}, [chatId, setChat]);
97+
}, [chatId, replaceChat]);
6498

6599
const setChatMessages = useCallback(
66100
(messages: UIMessage[]) => {
67101
const newId = `${chatId}-${Date.now()}`;
68-
setChat(new Chat<UIMessage>({ id: newId, messages }));
102+
replaceChat(new Chat<UIMessage>({ id: newId, messages }));
69103
},
70-
[chatId, setChat]
104+
[chatId, replaceChat]
71105
);
72106

73107
const value = useMemo(
74108
() => ({
75109
open,
76110
setOpen,
77111
chat,
112+
chatVersion,
78113
resetChat,
79114
setChatMessages,
80115
quickPrompts,
81116
setQuickPrompts
82117
}),
83-
[open, setOpen, chat, resetChat, setChatMessages, quickPrompts]
118+
[open, setOpen, chat, chatVersion, resetChat, setChatMessages, quickPrompts]
84119
);
85120

86121
return (
@@ -191,6 +226,7 @@ export function AiChatPopover({
191226
const open = controlledOpen ?? context?.open ?? localOpen;
192227
const handleOpenChange = onOpenChange ?? context?.setOpen ?? setLocalOpen;
193228
const chat = context?.chat ?? localChat;
229+
const chatVersion = context?.chatVersion ?? 0;
194230
const resetChat =
195231
context?.resetChat ??
196232
(() =>
@@ -233,7 +269,7 @@ export function AiChatPopover({
233269
className="overflow-hidden rounded-[inherit]"
234270
>
235271
<AIChat
236-
key={chat.id}
272+
key={`${chat.id}-${chatVersion}`}
237273
chat={chat}
238274
className="h-full"
239275
onClose={() => handleOpenChange(false)}

src/lib/ai-chat-history.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import type { UIMessage } from "ai";
2+
3+
const DB_NAME = "flanksource-ai-chat";
4+
const DB_VERSION = 1;
5+
const ACTIVE_STORE = "ai_active_conversation";
6+
const ACTIVE_KEY = "active";
7+
8+
type ActiveConversationRecord = {
9+
key: string;
10+
conversationId: string;
11+
messages: UIMessage[];
12+
updatedAt: number;
13+
};
14+
15+
export type ActiveAIConversation = {
16+
conversationId: string;
17+
messages: UIMessage[];
18+
updatedAt: number;
19+
};
20+
21+
let databasePromise: Promise<IDBDatabase | null> | null = null;
22+
23+
function isIndexedDBAvailable() {
24+
return (
25+
typeof window !== "undefined" && typeof window.indexedDB !== "undefined"
26+
);
27+
}
28+
29+
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
30+
return new Promise((resolve, reject) => {
31+
request.onsuccess = () => resolve(request.result);
32+
request.onerror = () => {
33+
reject(request.error ?? new Error("IndexedDB request failed"));
34+
};
35+
});
36+
}
37+
38+
function transactionDone(transaction: IDBTransaction): Promise<void> {
39+
return new Promise((resolve, reject) => {
40+
transaction.oncomplete = () => resolve();
41+
transaction.onerror = () => {
42+
reject(transaction.error ?? new Error("IndexedDB transaction failed"));
43+
};
44+
transaction.onabort = () => {
45+
reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
46+
};
47+
});
48+
}
49+
50+
function toActiveConversation(candidate: unknown): ActiveAIConversation | null {
51+
if (!candidate || typeof candidate !== "object") {
52+
return null;
53+
}
54+
55+
const maybeRecord = candidate as Partial<ActiveConversationRecord>;
56+
57+
if (typeof maybeRecord.conversationId !== "string") {
58+
return null;
59+
}
60+
61+
const conversationId = maybeRecord.conversationId.trim();
62+
63+
if (!conversationId) {
64+
return null;
65+
}
66+
67+
return {
68+
conversationId,
69+
messages: Array.isArray(maybeRecord.messages)
70+
? (maybeRecord.messages as UIMessage[])
71+
: [],
72+
updatedAt:
73+
typeof maybeRecord.updatedAt === "number" &&
74+
Number.isFinite(maybeRecord.updatedAt)
75+
? maybeRecord.updatedAt
76+
: Date.now()
77+
};
78+
}
79+
80+
function openDatabase(): Promise<IDBDatabase | null> {
81+
if (!isIndexedDBAvailable()) {
82+
return Promise.resolve(null);
83+
}
84+
85+
if (databasePromise) {
86+
return databasePromise;
87+
}
88+
89+
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
90+
const request = window.indexedDB.open(DB_NAME, DB_VERSION);
91+
92+
request.onupgradeneeded = () => {
93+
const database = request.result;
94+
95+
if (!database.objectStoreNames.contains(ACTIVE_STORE)) {
96+
database.createObjectStore(ACTIVE_STORE, { keyPath: "key" });
97+
}
98+
};
99+
100+
request.onsuccess = () => resolve(request.result);
101+
request.onerror = () => {
102+
reject(request.error ?? new Error("Failed to open IndexedDB"));
103+
};
104+
}).catch((error) => {
105+
databasePromise = null;
106+
throw error;
107+
});
108+
109+
return databasePromise;
110+
}
111+
112+
export async function loadActiveAIConversation(): Promise<ActiveAIConversation | null> {
113+
try {
114+
const database = await openDatabase();
115+
116+
if (!database) {
117+
return null;
118+
}
119+
120+
const transaction = database.transaction(ACTIVE_STORE, "readonly");
121+
const transactionCompleted = transactionDone(transaction);
122+
const store = transaction.objectStore(ACTIVE_STORE);
123+
const activeRecord = await requestToPromise(store.get(ACTIVE_KEY));
124+
125+
await transactionCompleted;
126+
127+
return toActiveConversation(activeRecord);
128+
} catch {
129+
return null;
130+
}
131+
}
132+
133+
export async function saveActiveAIConversation(
134+
conversationId: string,
135+
messages: UIMessage[]
136+
): Promise<void> {
137+
const normalizedConversationId = conversationId.trim();
138+
139+
if (!normalizedConversationId || messages.length === 0) {
140+
return;
141+
}
142+
143+
try {
144+
const database = await openDatabase();
145+
146+
if (!database) {
147+
return;
148+
}
149+
150+
const transaction = database.transaction(ACTIVE_STORE, "readwrite");
151+
const transactionCompleted = transactionDone(transaction);
152+
const store = transaction.objectStore(ACTIVE_STORE);
153+
154+
const nextRecord: ActiveConversationRecord = {
155+
key: ACTIVE_KEY,
156+
conversationId: normalizedConversationId,
157+
messages,
158+
updatedAt: Date.now()
159+
};
160+
161+
await requestToPromise(store.put(nextRecord));
162+
163+
await transactionCompleted;
164+
} catch {
165+
// Ignore IndexedDB write failures.
166+
}
167+
}
168+
169+
export async function clearActiveAIConversation(): Promise<void> {
170+
try {
171+
const database = await openDatabase();
172+
173+
if (!database) {
174+
return;
175+
}
176+
177+
const transaction = database.transaction(ACTIVE_STORE, "readwrite");
178+
const transactionCompleted = transactionDone(transaction);
179+
const store = transaction.objectStore(ACTIVE_STORE);
180+
181+
await requestToPromise(store.delete(ACTIVE_KEY));
182+
183+
await transactionCompleted;
184+
} catch {
185+
// Ignore IndexedDB write failures.
186+
}
187+
}

0 commit comments

Comments
 (0)