Skip to content

Commit 3e2ba10

Browse files
committed
feat(ai-chat): add local conversation history and per-user partitioning
1 parent b3ad3d1 commit 3e2ba10

File tree

3 files changed

+591
-85
lines changed

3 files changed

+591
-85
lines changed

src/components/ai/AiChat.tsx

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
ConversationEmptyState,
66
ConversationScrollButton
77
} from "@flanksource-ui/components/ai-elements/conversation";
8-
import { SquarePen, X } from "lucide-react";
8+
import { Check, History, SquarePen, Trash2, X } from "lucide-react";
99
import { Loader } from "@flanksource-ui/components/ai-elements/loader";
1010
import {
1111
Confirmation,
@@ -44,6 +44,14 @@ import {
4444
} from "@flanksource-ui/components/ai-elements/suggestion";
4545
import { Button } from "@flanksource-ui/components/ui/button";
4646
import { Card } from "@flanksource-ui/components/ui/card";
47+
import {
48+
DropdownMenu,
49+
DropdownMenuContent,
50+
DropdownMenuItem,
51+
DropdownMenuLabel,
52+
DropdownMenuSeparator,
53+
DropdownMenuTrigger
54+
} from "@flanksource-ui/components/ui/dropdown-menu";
4755
import {
4856
ChartConfig,
4957
ChartContainer,
@@ -52,8 +60,8 @@ import {
5260
} from "@flanksource-ui/components/ui/chart";
5361
import { formatTick, parseTimestamp } from "@flanksource-ui/lib/timeseries";
5462
import {
55-
clearActiveAIConversation,
56-
saveActiveAIConversation
63+
saveAIConversation,
64+
type AIConversationRecord
5765
} from "@flanksource-ui/lib/ai-chat-history";
5866
import { cn } from "@flanksource-ui/lib/utils";
5967
import type { FileUIPart, ReasoningUIPart, UIMessage } from "ai";
@@ -70,6 +78,45 @@ type PlotTimeseriesOutput = {
7078
title?: string;
7179
};
7280

81+
const HISTORY_PREVIEW_MAX_LENGTH = 72;
82+
83+
function getConversationPreview(messages: UIMessage[]): string {
84+
for (const message of messages) {
85+
if (message.role !== "user") {
86+
continue;
87+
}
88+
89+
for (const part of message.parts) {
90+
if (part.type !== "text") {
91+
continue;
92+
}
93+
94+
const normalizedText = part.text.replace(/\s+/g, " ").trim();
95+
96+
if (!normalizedText) {
97+
continue;
98+
}
99+
100+
if (normalizedText.length <= HISTORY_PREVIEW_MAX_LENGTH) {
101+
return normalizedText;
102+
}
103+
104+
return `${normalizedText.slice(0, HISTORY_PREVIEW_MAX_LENGTH - 1)}…`;
105+
}
106+
}
107+
108+
return "Untitled conversation";
109+
}
110+
111+
function formatConversationTime(updatedAt: number): string {
112+
return new Date(updatedAt).toLocaleString("en-US", {
113+
month: "short",
114+
day: "numeric",
115+
hour: "2-digit",
116+
minute: "2-digit"
117+
});
118+
}
119+
73120
const isPlotTimeseriesOutput = (
74121
output: unknown
75122
): output is PlotTimeseriesOutput => {
@@ -194,6 +241,15 @@ export type AIChatProps = {
194241
onClose?: () => void;
195242
onNewChat?: () => void;
196243
quickPrompts?: string[];
244+
activeConversationId?: string;
245+
conversationHistory?: AIConversationRecord[];
246+
onSelectConversation?: (conversationId: string) => void;
247+
onDeleteConversation?: (conversationId: string) => void;
248+
onConversationPersisted?: (
249+
conversationId: string,
250+
messages: UIMessage[]
251+
) => void;
252+
storageScopeKey?: string;
197253
};
198254

199255
export function AIChat({
@@ -202,7 +258,13 @@ export function AIChat({
202258
id,
203259
onClose,
204260
onNewChat,
205-
quickPrompts
261+
quickPrompts,
262+
activeConversationId,
263+
conversationHistory,
264+
onSelectConversation,
265+
onDeleteConversation,
266+
onConversationPersisted,
267+
storageScopeKey
206268
}: AIChatProps) {
207269
const {
208270
messages,
@@ -223,8 +285,9 @@ export function AIChat({
223285
return;
224286
}
225287

226-
void saveActiveAIConversation(chat.id, messages);
227-
}, [chat.id, messages]);
288+
void saveAIConversation(chat.id, messages, storageScopeKey);
289+
onConversationPersisted?.(chat.id, messages);
290+
}, [chat.id, messages, onConversationPersisted, storageScopeKey]);
228291

229292
// Auto-send when chat mounts with a pre-seeded user message (e.g. from setChatMessages).
230293
const hasSentOnMount = useRef(false);
@@ -277,8 +340,6 @@ export function AIChat({
277340
);
278341

279342
const handleNewChat = useCallback(() => {
280-
void clearActiveAIConversation();
281-
282343
if (onNewChat) {
283344
onNewChat();
284345
} else {
@@ -500,6 +561,10 @@ export function AIChat({
500561
);
501562
};
502563

564+
const visibleConversationHistory = useMemo(() => {
565+
return (conversationHistory ?? []).slice(0, 20);
566+
}, [conversationHistory]);
567+
503568
const errorMessage = error
504569
? error instanceof Error
505570
? error.message
@@ -510,6 +575,75 @@ export function AIChat({
510575
<div className={cn("flex h-full flex-1 flex-col gap-4", className)}>
511576
<Card className="relative flex h-full flex-1 flex-col bg-card">
512577
<div className="absolute left-3 top-3 z-10 flex items-center gap-2">
578+
{onSelectConversation ? (
579+
<DropdownMenu>
580+
<DropdownMenuTrigger asChild>
581+
<Button
582+
aria-label="View conversation history"
583+
size="sm"
584+
variant="outline"
585+
>
586+
<History className="mr-2 h-3.5 w-3.5" />
587+
History
588+
</Button>
589+
</DropdownMenuTrigger>
590+
<DropdownMenuContent align="start" className="w-80">
591+
<DropdownMenuLabel>Conversation History</DropdownMenuLabel>
592+
<DropdownMenuSeparator />
593+
{visibleConversationHistory.length > 0 ? (
594+
visibleConversationHistory.map((conversation) => {
595+
const isActive = activeConversationId === conversation.id;
596+
597+
return (
598+
<DropdownMenuItem
599+
className="group"
600+
key={conversation.id}
601+
onSelect={() => onSelectConversation(conversation.id)}
602+
>
603+
<div className="flex min-w-0 flex-1 flex-col">
604+
<span className="truncate font-medium">
605+
{getConversationPreview(conversation.messages)}
606+
</span>
607+
<span className="truncate text-xs text-muted-foreground">
608+
{formatConversationTime(conversation.updatedAt)}
609+
</span>
610+
</div>
611+
<div className="ml-2 flex items-center gap-1">
612+
{isActive ? (
613+
<Check className="h-4 w-4 text-primary" />
614+
) : null}
615+
{onDeleteConversation ? (
616+
<button
617+
type="button"
618+
aria-label="Delete conversation"
619+
className="rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 group-focus:opacity-100"
620+
title="Delete conversation"
621+
onPointerDown={(event) => {
622+
event.preventDefault();
623+
event.stopPropagation();
624+
}}
625+
onClick={(event) => {
626+
event.preventDefault();
627+
event.stopPropagation();
628+
onDeleteConversation(conversation.id);
629+
}}
630+
>
631+
<Trash2 className="h-3.5 w-3.5" />
632+
</button>
633+
) : null}
634+
</div>
635+
</DropdownMenuItem>
636+
);
637+
})
638+
) : (
639+
<DropdownMenuItem disabled>
640+
No saved conversations yet
641+
</DropdownMenuItem>
642+
)}
643+
</DropdownMenuContent>
644+
</DropdownMenu>
645+
) : null}
646+
513647
{onNewChat ? (
514648
<Button
515649
aria-label="Start a new conversation"

0 commit comments

Comments
 (0)