diff --git a/src/components/ai/AiChat.tsx b/src/components/ai/AiChat.tsx index a84166c07..5381e2439 100644 --- a/src/components/ai/AiChat.tsx +++ b/src/components/ai/AiChat.tsx @@ -5,7 +5,7 @@ import { ConversationEmptyState, ConversationScrollButton } from "@flanksource-ui/components/ai-elements/conversation"; -import { SquarePen, X } from "lucide-react"; +import { Check, History, SquarePen, Trash2, X } from "lucide-react"; import { Loader } from "@flanksource-ui/components/ai-elements/loader"; import { Confirmation, @@ -44,6 +44,14 @@ import { } from "@flanksource-ui/components/ai-elements/suggestion"; import { Button } from "@flanksource-ui/components/ui/button"; import { Card } from "@flanksource-ui/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@flanksource-ui/components/ui/dropdown-menu"; import { ChartConfig, ChartContainer, @@ -51,6 +59,10 @@ import { ChartTooltipContent } from "@flanksource-ui/components/ui/chart"; import { formatTick, parseTimestamp } from "@flanksource-ui/lib/timeseries"; +import { + saveAIConversation, + type AIConversationRecord +} from "@flanksource-ui/lib/ai-chat-history"; import { cn } from "@flanksource-ui/lib/utils"; import type { FileUIPart, ReasoningUIPart, UIMessage } from "ai"; import { getToolName, isToolUIPart } from "ai"; @@ -66,6 +78,45 @@ type PlotTimeseriesOutput = { title?: string; }; +const HISTORY_PREVIEW_MAX_LENGTH = 72; + +function getConversationPreview(messages: UIMessage[]): string { + for (const message of messages) { + if (message.role !== "user") { + continue; + } + + for (const part of message.parts) { + if (part.type !== "text") { + continue; + } + + const normalizedText = part.text.replace(/\s+/g, " ").trim(); + + if (!normalizedText) { + continue; + } + + if (normalizedText.length <= HISTORY_PREVIEW_MAX_LENGTH) { + return normalizedText; + } + + return `${normalizedText.slice(0, HISTORY_PREVIEW_MAX_LENGTH - 1)}…`; + } + } + + return "Untitled conversation"; +} + +function formatConversationTime(updatedAt: number): string { + return new Date(updatedAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + const isPlotTimeseriesOutput = ( output: unknown ): output is PlotTimeseriesOutput => { @@ -190,6 +241,15 @@ export type AIChatProps = { onClose?: () => void; onNewChat?: () => void; quickPrompts?: string[]; + activeConversationId?: string; + conversationHistory?: AIConversationRecord[]; + onSelectConversation?: (conversationId: string) => void; + onDeleteConversation?: (conversationId: string) => void; + onConversationPersisted?: ( + conversationId: string, + messages: UIMessage[] + ) => void; + storageScopeKey?: string; }; export function AIChat({ @@ -198,7 +258,13 @@ export function AIChat({ id, onClose, onNewChat, - quickPrompts + quickPrompts, + activeConversationId, + conversationHistory, + onSelectConversation, + onDeleteConversation, + onConversationPersisted, + storageScopeKey }: AIChatProps) { const { messages, @@ -214,6 +280,15 @@ export function AIChat({ id }); + useEffect(() => { + if (!chat.id || messages.length === 0) { + return; + } + + void saveAIConversation(chat.id, messages, storageScopeKey); + onConversationPersisted?.(chat.id, messages); + }, [chat.id, messages, onConversationPersisted, storageScopeKey]); + // Auto-send when chat mounts with a pre-seeded user message (e.g. from setChatMessages). const hasSentOnMount = useRef(false); useEffect(() => { @@ -265,11 +340,15 @@ export function AIChat({ ); const handleNewChat = useCallback(() => { - setMessages([]); + if (onNewChat) { + onNewChat(); + } else { + setMessages([]); + } + if (error) { clearError(); } - onNewChat?.(); }, [clearError, error, onNewChat, setMessages]); const handleSuggestionClick = useCallback( @@ -482,6 +561,10 @@ export function AIChat({ ); }; + const visibleConversationHistory = useMemo(() => { + return (conversationHistory ?? []).slice(0, 20); + }, [conversationHistory]); + const errorMessage = error ? error instanceof Error ? error.message @@ -492,6 +575,75 @@ export function AIChat({
+ {onSelectConversation ? ( + + + + + + Conversation History + + {visibleConversationHistory.length > 0 ? ( + visibleConversationHistory.map((conversation) => { + const isActive = activeConversationId === conversation.id; + + return ( + onSelectConversation(conversation.id)} + > +
+ + {getConversationPreview(conversation.messages)} + + + {formatConversationTime(conversation.updatedAt)} + +
+
+ {isActive ? ( + + ) : null} + {onDeleteConversation ? ( + + ) : null} +
+
+ ); + }) + ) : ( + + No saved conversations yet + + )} +
+
+ ) : null} + {onNewChat ? (