diff --git a/package.json b/package.json index 0d7e4c02..0e928de8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -53,6 +54,7 @@ "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "^4.1.0", + "dexie": "^4.2.1", "jose": "^6.1.2", "json-schema-faker": "^0.5.6", "lucide-react": "^0.562.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0ad9f1f..c03e5b26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -86,6 +89,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dexie: + specifier: ^4.2.1 + version: 4.2.1 jose: specifier: ^6.1.2 version: 6.1.3 @@ -1375,6 +1381,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -2779,6 +2798,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -5716,6 +5738,29 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -7086,6 +7131,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.2.1: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} diff --git a/src/components/chat/chat-header.tsx b/src/components/chat/chat-header.tsx index 5a3df33f..96d56a8b 100644 --- a/src/components/chat/chat-header.tsx +++ b/src/components/chat/chat-header.tsx @@ -1,50 +1,100 @@ "use client"; -import { Trash2 } from "lucide-react"; +import { ChevronDown, Plus } from "lucide-react"; +import { useState } from "react"; +import type { StoredConversation } from "@/features/assistant/db"; import { useConfirm } from "@/hooks/use-confirm"; import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { ConversationList } from "./conversation-list"; interface ChatHeaderProps { + conversations: StoredConversation[]; + currentConversationId: string | null; + onSelectConversation: (id: string) => void; + onDeleteConversation: (id: string) => void; + onNewConversation: () => void; + onClearAll: () => Promise; hasMessages: boolean; - onClearMessages?: () => void; } -export function ChatHeader({ hasMessages, onClearMessages }: ChatHeaderProps) { +export function ChatHeader({ + conversations, + currentConversationId, + onSelectConversation, + onDeleteConversation, + onNewConversation, + onClearAll, + hasMessages, +}: ChatHeaderProps) { const { confirm, ConfirmDialog } = useConfirm(); + const [isOpen, setIsOpen] = useState(false); - const handleClearMessages = async () => { - if (!onClearMessages) return; + const handleNewConversation = () => { + onNewConversation(); + }; + + const handleDeleteConversation = async (id: string) => { const confirmed = await confirm({ - title: "Clear all messages?", - description: "Are you sure you want to delete all messages?", - confirmText: "Clear", + title: "Delete conversation?", + description: "This will permanently delete this conversation.", + confirmText: "Delete", cancelText: "Cancel", }); if (confirmed) { - onClearMessages(); + onDeleteConversation(id); + } + }; + + const handleClearAll = async () => { + const confirmed = await confirm({ + title: "Clear all conversations?", + description: + "This will permanently delete all conversations and messages.", + confirmText: "Clear All", + cancelText: "Cancel", + }); + if (confirmed) { + await onClearAll(); + setIsOpen(false); } }; return ( <>
-
-

Assistant

-

- Chat with AI using MCP servers -

-
- {hasMessages && onClearMessages && ( - - )} + + + + + + setIsOpen(false)} + /> + + + +

+ Chat with AI using MCP servers +

+ +
{ConfirmDialog} diff --git a/src/components/chat/chat-interface.tsx b/src/components/chat/chat-interface.tsx index d0213370..61f72601 100644 --- a/src/components/chat/chat-interface.tsx +++ b/src/components/chat/chat-interface.tsx @@ -1,37 +1,35 @@ "use client"; -import type { ChatStatus, FileUIPart, UIMessage } from "ai"; import { ChevronDown } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { useChatContext } from "@/features/assistant/contexts/chat-context"; import { useAutoScroll } from "@/hooks/use-auto-scroll"; import { ChatEmptyState } from "./chat-empty-state"; import { ChatHeader } from "./chat-header"; import { ChatInputPrompt } from "./chat-input"; import { ChatMessages } from "./chat-messages"; -import { ErrorAlert } from "./error-alert"; -interface ChatInterfaceProps { - messages: UIMessage[]; - status: ChatStatus; - error?: Error; - cancelRequest: () => void; - onClearMessages?: () => void; - sendMessage: (args: { text: string; files?: FileUIPart[] }) => Promise; - selectedModel: string; - onModelChange: (model: string) => void; -} +export function ChatInterface() { + const { + messages, + status, + error, + stop, + clearError, + sendMessage, + selectedModel, + setSelectedModel, + clearMessages, + conversations, + currentConversationId, + loadConversation, + deleteConversation, + clearAllConversations, + } = useChatContext(); -export function ChatInterface({ - messages, - status, - error, - cancelRequest, - onClearMessages, - sendMessage, - selectedModel, - onModelChange, -}: ChatInterfaceProps) { const { messagesEndRef, messagesContainerRef, @@ -41,10 +39,38 @@ export function ChatInterface({ const isLoading = status === "streaming" || status === "submitted"; const hasMessages = messages.length > 0; + const previousErrorRef = useRef(null); + + useEffect(() => { + const errorMessage = error?.message ?? null; + if (errorMessage && errorMessage !== previousErrorRef.current) { + toast.error("Something went wrong", { + description: errorMessage, + }); + } + previousErrorRef.current = errorMessage; + }, [error]); + + const handleCancelRequest = async () => { + await stop(); + clearError(); + }; + + const handleNewConversation = () => { + clearMessages(); + }; return (
- + {hasMessages && } @@ -61,9 +87,9 @@ export function ChatInterface({ )} @@ -71,25 +97,23 @@ export function ChatInterface({ )}
- - {hasMessages && (
)} diff --git a/src/components/chat/conversation-list.tsx b/src/components/chat/conversation-list.tsx new file mode 100644 index 00000000..89c5d4a2 --- /dev/null +++ b/src/components/chat/conversation-list.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { MessageSquare, Search, Trash, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { StoredConversation } from "@/features/assistant/db"; +import { cn } from "@/lib/utils"; + +interface ConversationListProps { + conversations: StoredConversation[]; + currentConversationId: string | null; + onSelectConversation: (id: string) => void; + onDeleteConversation: (id: string) => void; + onClearAll: () => void; + onClose?: () => void; +} + +export function ConversationList({ + conversations, + currentConversationId, + onSelectConversation, + onDeleteConversation, + onClearAll, + onClose, +}: ConversationListProps) { + const [searchQuery, setSearchQuery] = useState(""); + + const filteredConversations = useMemo(() => { + if (!searchQuery.trim()) { + return conversations; + } + const query = searchQuery.toLowerCase(); + return conversations.filter( + (conv) => + conv.title?.toLowerCase().includes(query) || + conv.model?.toLowerCase().includes(query), + ); + }, [conversations, searchQuery]); + + const handleSelect = (id: string) => { + onSelectConversation(id); + onClose?.(); + }; + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+
+ +
+ {filteredConversations.length === 0 ? ( +
+ {searchQuery ? "No conversations found" : "No conversations yet"} +
+ ) : ( +
+ {filteredConversations.map((conv) => ( + handleSelect(conv.id)} + onDelete={() => onDeleteConversation(conv.id)} + /> + ))} +
+ )} +
+ + {conversations.length > 0 && ( +
+ +
+ )} +
+ ); +} + +interface ConversationItemProps { + conversation: StoredConversation; + isActive: boolean; + onSelect: () => void; + onDelete: () => void; +} + +function ConversationItem({ + conversation, + isActive, + onSelect, + onDelete, +}: ConversationItemProps) { + const title = conversation.title || "New conversation"; + const timeAgo = formatDistanceToNow(new Date(conversation.updatedAt), { + addSuffix: true, + }); + + return ( +
+ + +
+ ); +} diff --git a/src/components/chat/message/image-modal.tsx b/src/components/chat/message/image-modal.tsx index 0ec96aab..270cccad 100644 --- a/src/components/chat/message/image-modal.tsx +++ b/src/components/chat/message/image-modal.tsx @@ -3,6 +3,7 @@ import { X } from "lucide-react"; import Image from "next/image"; import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; interface ImageModalProps { isOpen: boolean; @@ -42,14 +43,15 @@ export function ImageModal({ className="fixed inset-0 z-50 flex items-center justify-center bg-black/80" onClick={onClose} > - + {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only prevents backdrop close */} {/* biome-ignore lint/a11y/noStaticElementInteractions: container for image */} diff --git a/src/components/chat/message/reasoning.tsx b/src/components/chat/message/reasoning.tsx index f63126f0..061249b2 100644 --- a/src/components/chat/message/reasoning.tsx +++ b/src/components/chat/message/reasoning.tsx @@ -5,6 +5,7 @@ import { Brain, ChevronDown, ChevronRight } from "lucide-react"; import { useState } from "react"; import remarkGfm from "remark-gfm"; import { Streamdown } from "streamdown"; +import { Button } from "@/components/ui/button"; import type { MessagePart } from "./helpers"; interface ReasoningProps { @@ -35,10 +36,11 @@ export function Reasoning({ part, status }: ReasoningProps) {
- + {isOpen && (
diff --git a/src/components/chat/message/tool-call.tsx b/src/components/chat/message/tool-call.tsx index ec79f74c..db1a9d7d 100644 --- a/src/components/chat/message/tool-call.tsx +++ b/src/components/chat/message/tool-call.tsx @@ -8,6 +8,7 @@ import { Wrench, } from "lucide-react"; import { useState } from "react"; +import { Button } from "@/components/ui/button"; import type { MessagePart } from "./helpers"; import { ToolOutputContent } from "./tool-output"; @@ -61,10 +62,11 @@ export function ToolCall({ part }: ToolCallProps) {
- + {isDetailsOpen && (
@@ -106,10 +108,11 @@ export function ToolCall({ part }: ToolCallProps) { {"input" in part && part.input !== undefined && (
- + {isInputOpen && (
@@ -133,10 +136,11 @@ export function ToolCall({ part }: ToolCallProps) {
 
       {"output" in part && part.output !== undefined && (
         
- + {isOutputOpen && (
diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 820083f2..5b95a414 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -11,12 +11,12 @@ export async function Navbar() { return (
+
- + {session?.user?.name && }
- +
- {session?.user?.name && }
); } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..d68b87a6 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Popover({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/features/assistant/components/sidebar-content.tsx b/src/features/assistant/components/sidebar-content.tsx index ab16462d..6c55f1b3 100644 --- a/src/features/assistant/components/sidebar-content.tsx +++ b/src/features/assistant/components/sidebar-content.tsx @@ -1,36 +1,11 @@ "use client"; import { ChatInterface } from "@/components/chat/chat-interface"; -import { useChatContext } from "@/features/assistant/contexts/chat-context"; export function AssistantSidebarContent() { - const { - messages, - sendMessage, - status, - clearError, - stop, - error, - clearMessages, - selectedModel, - setSelectedModel, - } = useChatContext(); - return (
- { - await stop(); - clearError(); - }} - onClearMessages={clearMessages} - sendMessage={sendMessage} - selectedModel={selectedModel} - onModelChange={setSelectedModel} - /> +
); } diff --git a/src/features/assistant/components/sidebar.tsx b/src/features/assistant/components/sidebar.tsx index 0a8789ea..ca356d86 100644 --- a/src/features/assistant/components/sidebar.tsx +++ b/src/features/assistant/components/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { MessageCircle, PanelLeftClose } from "lucide-react"; +import { MessageCircle, PanelRightClose } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Sidebar, @@ -13,12 +13,10 @@ import { AssistantSidebarContent } from "./sidebar-content"; export function AssistantSidebar() { const { state, toggleSidebar, isMobile, openMobile } = useSidebar(); const isCollapsed = state === "collapsed"; - - // Show content when: on mobile and sheet is open, OR on desktop and not collapsed const showContent = isMobile ? openMobile : !isCollapsed; return ( - +
@@ -31,7 +29,7 @@ export function AssistantSidebar() { onClick={toggleSidebar} aria-label="Close sidebar" > - + diff --git a/src/features/assistant/contexts/chat-context.tsx b/src/features/assistant/contexts/chat-context.tsx index 8d9be810..e732203d 100644 --- a/src/features/assistant/contexts/chat-context.tsx +++ b/src/features/assistant/contexts/chat-context.tsx @@ -1,9 +1,11 @@ "use client"; import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; -import { createContext, useContext, useMemo, useRef, useState } from "react"; +import { createContext, useContext, useState } from "react"; import { DEFAULT_MODEL } from "@/features/assistant/constants"; +import { useChatHistory } from "@/features/assistant/hooks/use-chat-history"; +import { useChatPersistence } from "@/features/assistant/hooks/use-chat-persistence"; +import { useChatTransport } from "@/features/assistant/hooks/use-chat-transport"; import { useMcpSettings } from "@/features/assistant/hooks/use-mcp-settings"; type ChatHelpers = ReturnType; @@ -12,6 +14,11 @@ interface ChatContextValue extends ChatHelpers { selectedModel: string; setSelectedModel: (model: string) => void; clearMessages: () => void; + conversations: ReturnType["conversations"]; + currentConversationId: string | null; + loadConversation: (id: string) => Promise; + deleteConversation: (id: string) => Promise; + clearAllConversations: () => Promise; } const ChatContext = createContext(null); @@ -19,53 +26,66 @@ const ChatContext = createContext(null); export function ChatProvider({ children }: { children: React.ReactNode }) { const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); const { selectedServers, enabledTools } = useMcpSettings(); + const chatHistory = useChatHistory(); - // Use refs to access current values in the transport callback - const selectedModelRef = useRef(selectedModel); - selectedModelRef.current = selectedModel; - - const selectedServersRef = useRef(selectedServers); - selectedServersRef.current = selectedServers; - - const enabledToolsRef = useRef(enabledTools); - enabledToolsRef.current = enabledTools; - - // Create transport with prepareSendMessagesRequest to inject settings dynamically - const transport = useMemo( - () => - new DefaultChatTransport({ - api: "/api/chat", - prepareSendMessagesRequest: ({ messages, body, ...rest }) => { - // Convert Set to array for selectedServers - const serversArray = Array.from(selectedServersRef.current); - - // Convert Map> to Record - const toolsRecord: Record = {}; - for (const [serverName, toolsSet] of enabledToolsRef.current) { - toolsRecord[serverName] = Array.from(toolsSet); - } - - return { - ...rest, - body: { - messages, - ...body, - model: selectedModelRef.current, - selectedServers: serversArray, - enabledTools: toolsRecord, - }, - }; - }, - }), - [], - ); - - const chatHelpers = useChat({ - transport, + const transport = useChatTransport({ + selectedModel, + selectedServers, + enabledTools, + }); + + const chatHelpers = useChat({ transport }); + + const { isLoadingConversation } = useChatPersistence({ + chatHistory, + messages: chatHelpers.messages, + status: chatHelpers.status, + setMessages: chatHelpers.setMessages, + selectedModel, + setSelectedModel, + selectedServers, }); const clearMessages = () => { chatHelpers.setMessages([]); + chatHistory + .startNewConversation(selectedModel, Array.from(selectedServers)) + .catch((error) => { + console.error( + "[ChatProvider] Failed to start new conversation:", + error, + ); + }); + }; + + const handleLoadConversation = async (conversationId: string) => { + isLoadingConversation.current = true; + try { + const messages = await chatHistory.loadConversation(conversationId); + chatHelpers.setMessages(messages); + + const conv = chatHistory.conversations.find( + (c) => c.id === conversationId, + ); + if (conv?.model) { + setSelectedModel(conv.model); + } + } catch (error) { + isLoadingConversation.current = false; + throw error; + } + }; + + const handleDeleteConversation = async (conversationId: string) => { + await chatHistory.deleteConversation(conversationId); + if (conversationId === chatHistory.currentConversationId) { + chatHelpers.setMessages([]); + } + }; + + const handleClearAll = async () => { + await chatHistory.clearAll(); + chatHelpers.setMessages([]); }; const value: ChatContextValue = { @@ -73,6 +93,11 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { selectedModel, setSelectedModel, clearMessages, + conversations: chatHistory.conversations, + currentConversationId: chatHistory.currentConversationId, + loadConversation: handleLoadConversation, + deleteConversation: handleDeleteConversation, + clearAllConversations: handleClearAll, }; return {children}; diff --git a/src/features/assistant/db/database.ts b/src/features/assistant/db/database.ts new file mode 100644 index 00000000..3896604b --- /dev/null +++ b/src/features/assistant/db/database.ts @@ -0,0 +1,21 @@ +import Dexie, { type EntityTable } from "dexie"; +import type { StoredConversation, StoredMessage } from "./types"; + +/** + * Chat database schema using Dexie. + */ +class ChatDatabase extends Dexie { + conversations!: EntityTable; + messages!: EntityTable; + + constructor() { + super("ToolHiveCloudUIChatDB"); + + this.version(1).stores({ + conversations: "id, createdAt, updatedAt", + messages: "id, conversationId, createdAt, order, [conversationId+order]", + }); + } +} + +export const db = new ChatDatabase(); diff --git a/src/features/assistant/db/index.ts b/src/features/assistant/db/index.ts new file mode 100644 index 00000000..e353dd60 --- /dev/null +++ b/src/features/assistant/db/index.ts @@ -0,0 +1,3 @@ +export { db } from "./database"; +export * from "./storage"; +export * from "./types"; diff --git a/src/features/assistant/db/storage.ts b/src/features/assistant/db/storage.ts new file mode 100644 index 00000000..9f371059 --- /dev/null +++ b/src/features/assistant/db/storage.ts @@ -0,0 +1,189 @@ +import type { UIMessage } from "ai"; +import { db } from "./database"; +import type { StoredConversation, StoredMessage } from "./types"; + +/** + * Creates a new conversation and returns its ID. + */ +export async function createConversation( + title?: string, + model?: string, + selectedServers?: string[], +): Promise { + const id = `conv-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const now = Date.now(); + + await db.conversations.add({ + id, + title, + createdAt: now, + updatedAt: now, + model, + selectedServers, + }); + + return id; +} + +/** + * Gets all conversations ordered by most recent first. + */ +export async function getAllConversations(): Promise { + return db.conversations.orderBy("updatedAt").reverse().toArray(); +} + +/** + * Gets a conversation by ID. + */ +export async function getConversation( + id: string, +): Promise { + return db.conversations.get(id); +} + +/** + * Updates conversation metadata. + */ +export async function updateConversation( + id: string, + updates: Partial< + Pick + >, +): Promise { + await db.conversations.update(id, { + ...updates, + updatedAt: Date.now(), + }); +} + +/** + * Deletes a conversation and all its messages. + */ +export async function deleteConversation(id: string): Promise { + await db.transaction("rw", db.conversations, db.messages, async () => { + await db.messages.where("conversationId").equals(id).delete(); + await db.conversations.delete(id); + }); +} + +/** + * Saves a message to the database. + * Only saves user and assistant messages (filters out system messages). + */ +export async function saveMessage( + conversationId: string, + message: UIMessage, + order: number, +): Promise { + // Skip system messages - they're not visible in the UI + if (message.role === "system") { + return; + } + + const createdAt = + (message.metadata as { createdAt?: number } | undefined)?.createdAt ?? + Date.now(); + + await db.messages.add({ + id: message.id, + conversationId, + role: message.role as "user" | "assistant", + parts: message.parts, + metadata: message.metadata, + createdAt, + order, + }); + + // Update conversation updatedAt timestamp + await db.conversations.update(conversationId, { + updatedAt: Date.now(), + }); +} + +/** + * Saves multiple messages in bulk (more efficient). + * Only saves user and assistant messages (filters out system messages). + */ +export async function saveMessages( + conversationId: string, + messages: UIMessage[], +): Promise { + const now = Date.now(); + let order = 0; + const storedMessages: StoredMessage[] = []; + + for (const message of messages) { + // Skip system messages - they're not visible in the UI + if (message.role === "system") { + continue; + } + + const createdAt = + (message.metadata as { createdAt?: number } | undefined)?.createdAt ?? + now; + + storedMessages.push({ + id: message.id, + conversationId, + role: message.role as "user" | "assistant", + parts: message.parts, + metadata: message.metadata, + createdAt, + order, + }); + + order++; + } + + await db.transaction("rw", db.messages, db.conversations, async () => { + // Upsert messages (insert if new, update if exists) + await db.messages.bulkPut(storedMessages); + // Update conversation timestamp + await db.conversations.update(conversationId, { + updatedAt: Date.now(), + }); + }); +} + +/** + * Gets all messages for a conversation, ordered by creation order. + */ +export async function getMessages( + conversationId: string, +): Promise { + return db.messages + .where("conversationId") + .equals(conversationId) + .sortBy("order"); +} + +/** + * Converts stored messages back to UIMessage format. + */ +export function storedMessagesToUIMessages( + stored: StoredMessage[], +): UIMessage[] { + return stored.map((msg) => ({ + id: msg.id, + role: msg.role, + parts: msg.parts, + metadata: msg.metadata, + })); +} + +/** + * Deletes all messages for a conversation. + */ +export async function deleteMessages(conversationId: string): Promise { + await db.messages.where("conversationId").equals(conversationId).delete(); +} + +/** + * Deletes all conversations and messages. + */ +export async function deleteAllConversations(): Promise { + await db.transaction("rw", db.conversations, db.messages, async () => { + await db.messages.clear(); + await db.conversations.clear(); + }); +} diff --git a/src/features/assistant/db/types.ts b/src/features/assistant/db/types.ts new file mode 100644 index 00000000..5bc0e9f5 --- /dev/null +++ b/src/features/assistant/db/types.ts @@ -0,0 +1,26 @@ +import type { UIMessage } from "ai"; + +/** + * Stored conversation metadata. + */ +export interface StoredConversation { + id: string; + title?: string; + createdAt: number; + updatedAt: number; + model?: string; + selectedServers?: string[]; +} + +/** + * Stored message with reference to conversation. + */ +export interface StoredMessage { + id: string; + conversationId: string; + role: "user" | "assistant"; + parts: UIMessage["parts"]; + metadata?: UIMessage["metadata"]; + createdAt: number; + order: number; +} diff --git a/src/features/assistant/hooks/use-chat-history.ts b/src/features/assistant/hooks/use-chat-history.ts new file mode 100644 index 00000000..d6701f99 --- /dev/null +++ b/src/features/assistant/hooks/use-chat-history.ts @@ -0,0 +1,165 @@ +"use client"; + +import type { UIMessage } from "ai"; +import { useEffect, useRef, useState } from "react"; +import { + createConversation, + deleteAllConversations, + deleteConversation, + getAllConversations, + getConversation, + getMessages, + type StoredConversation, + saveMessages, + storedMessagesToUIMessages, + updateConversation, +} from "../db"; + +/** + * Extracts title from the first user message. + */ +function extractTitleFromMessages(messages: UIMessage[]): string | null { + const firstUserMessage = messages.find((m) => m.role === "user"); + if (!firstUserMessage) return null; + + const textContent = firstUserMessage.parts + .filter((p) => p.type === "text") + .map((p) => ("text" in p ? p.text : "")) + .join("") + .trim() + .slice(0, 100); + + return textContent || null; +} + +/** + * Hook for managing chat history with Dexie. + * Handles conversation creation, loading, and automatic message saving. + */ +export function useChatHistory() { + const [currentConversationId, setCurrentConversationId] = useState< + string | null + >(null); + const [conversations, setConversations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const isInitializedRef = useRef(false); + + useEffect(() => { + async function loadConversations() { + try { + const allConversations = await getAllConversations(); + setConversations(allConversations); + } catch (error) { + console.error("[useChatHistory] Failed to load conversations:", error); + } finally { + setIsLoading(false); + } + } + + if (!isInitializedRef.current) { + loadConversations(); + isInitializedRef.current = true; + } + }, []); + + /** + * Creates a new conversation and sets it as current. + */ + const startNewConversation = async ( + model?: string, + selectedServers?: string[], + ): Promise => { + const id = await createConversation(undefined, model, selectedServers); + setCurrentConversationId(id); + await refreshConversations(); + return id; + }; + + /** + * Loads a conversation and returns its messages. + */ + const loadConversation = async ( + conversationId: string, + ): Promise => { + const storedMessages = await getMessages(conversationId); + const messages = storedMessagesToUIMessages(storedMessages); + setCurrentConversationId(conversationId); + return messages; + }; + + /** + * Saves messages to the current conversation. + * Creates a new conversation if none exists. + */ + const saveCurrentMessages = async ( + messages: UIMessage[], + model?: string, + selectedServers?: string[], + ): Promise => { + if (messages.length === 0) { + return; + } + + let conversationId = currentConversationId; + + if (!conversationId) { + conversationId = await startNewConversation(model, selectedServers); + } + + if (model || selectedServers) { + await updateConversation(conversationId, { model, selectedServers }); + } + + await saveMessages(conversationId, messages); + + // Update title from first user message if not set + const currentConv = await getConversation(conversationId); + if (currentConv && !currentConv.title) { + const title = extractTitleFromMessages(messages); + if (title) { + await updateConversation(conversationId, { title }); + await refreshConversations(); + } + } + }; + + /** + * Deletes a conversation. + */ + const deleteConv = async (conversationId: string): Promise => { + await deleteConversation(conversationId); + if (conversationId === currentConversationId) { + setCurrentConversationId(null); + } + await refreshConversations(); + }; + + /** + * Refreshes the conversations list. + */ + const refreshConversations = async (): Promise => { + const allConversations = await getAllConversations(); + setConversations(allConversations); + }; + + /** + * Clears all conversations and messages. + */ + const clearAll = async (): Promise => { + await deleteAllConversations(); + setCurrentConversationId(null); + setConversations([]); + }; + + return { + currentConversationId, + conversations, + isLoading, + startNewConversation, + loadConversation, + saveCurrentMessages, + deleteConversation: deleteConv, + refreshConversations, + clearAll, + }; +} diff --git a/src/features/assistant/hooks/use-chat-persistence.ts b/src/features/assistant/hooks/use-chat-persistence.ts new file mode 100644 index 00000000..bc9f7ab1 --- /dev/null +++ b/src/features/assistant/hooks/use-chat-persistence.ts @@ -0,0 +1,108 @@ +"use client"; + +import type { UIMessage } from "ai"; +import { useEffect, useRef } from "react"; +import type { useChatHistory } from "./use-chat-history"; + +type ChatHistory = ReturnType; + +interface UseChatPersistenceOptions { + chatHistory: ChatHistory; + messages: UIMessage[]; + status: string; + setMessages: (messages: UIMessage[]) => void; + selectedModel: string; + setSelectedModel: (model: string) => void; + selectedServers: Set; +} + +interface UseChatPersistenceResult { + isLoadingConversation: React.RefObject; +} + +/** + * Handles chat persistence: auto-loading last conversation on mount + * and auto-saving messages when streaming completes. + */ +export function useChatPersistence({ + chatHistory, + messages, + status, + setMessages, + selectedModel, + setSelectedModel, + selectedServers, +}: UseChatPersistenceOptions): UseChatPersistenceResult { + const hasLoadedInitialConversation = useRef(false); + const isLoadingConversationRef = useRef(false); + const previousStatusRef = useRef(status); + + // Auto-load last conversation on mount + useEffect(() => { + if (chatHistory.isLoading || hasLoadedInitialConversation.current) { + return; + } + + if (messages.length > 0) { + hasLoadedInitialConversation.current = true; + return; + } + + if (chatHistory.conversations.length > 0) { + const lastConversation = chatHistory.conversations[0]; + isLoadingConversationRef.current = true; + + chatHistory + .loadConversation(lastConversation.id) + .then((loadedMessages) => { + if (loadedMessages.length > 0) { + setMessages(loadedMessages); + if (lastConversation.model) { + setSelectedModel(lastConversation.model); + } + } + }) + .catch((error) => { + console.error("[useChatPersistence] Failed to load:", error); + }) + .finally(() => { + isLoadingConversationRef.current = false; + }); + } + + hasLoadedInitialConversation.current = true; + }, [ + chatHistory.isLoading, + chatHistory.conversations, + chatHistory.loadConversation, + messages.length, + setMessages, + setSelectedModel, + ]); + + // Save when streaming completes (status changes from "streaming" to "ready") + useEffect(() => { + const wasStreaming = previousStatusRef.current === "streaming"; + const isNowReady = status === "ready"; + previousStatusRef.current = status; + + if (!wasStreaming || !isNowReady) { + return; + } + + if (messages.length === 0 || isLoadingConversationRef.current) { + return; + } + + const serversArray = Array.from(selectedServers); + chatHistory + .saveCurrentMessages(messages, selectedModel, serversArray) + .catch((error) => { + console.error("[useChatPersistence] Failed to save:", error); + }); + }, [status, messages, selectedModel, selectedServers, chatHistory]); + + return { + isLoadingConversation: isLoadingConversationRef, + }; +} diff --git a/src/features/assistant/hooks/use-chat-transport.ts b/src/features/assistant/hooks/use-chat-transport.ts new file mode 100644 index 00000000..b2768e95 --- /dev/null +++ b/src/features/assistant/hooks/use-chat-transport.ts @@ -0,0 +1,58 @@ +"use client"; + +import { DefaultChatTransport } from "ai"; +import { useMemo, useRef } from "react"; + +interface UseChatTransportOptions { + selectedModel: string; + selectedServers: Set; + enabledTools: Map>; +} + +/** + * Creates a chat transport that dynamically injects model, servers, and tools + * into each request without recreating the transport on every change. + */ +export function useChatTransport({ + selectedModel, + selectedServers, + enabledTools, +}: UseChatTransportOptions) { + // Use refs to access current values in the transport callback + // This avoids recreating the transport when these values change + const selectedModelRef = useRef(selectedModel); + selectedModelRef.current = selectedModel; + + const selectedServersRef = useRef(selectedServers); + selectedServersRef.current = selectedServers; + + const enabledToolsRef = useRef(enabledTools); + enabledToolsRef.current = enabledTools; + + return useMemo( + () => + new DefaultChatTransport({ + api: "/api/chat", + prepareSendMessagesRequest: ({ messages, body, ...rest }) => { + const serversArray = Array.from(selectedServersRef.current); + + const toolsRecord: Record = {}; + for (const [serverName, toolsSet] of enabledToolsRef.current) { + toolsRecord[serverName] = Array.from(toolsSet); + } + + return { + ...rest, + body: { + messages, + ...body, + model: selectedModelRef.current, + selectedServers: serversArray, + enabledTools: toolsRecord, + }, + }; + }, + }), + [], + ); +} diff --git a/src/features/assistant/index.ts b/src/features/assistant/index.ts index 5b903874..c60f5c04 100644 --- a/src/features/assistant/index.ts +++ b/src/features/assistant/index.ts @@ -25,7 +25,7 @@ export { type ToolInfo, } from "./contexts/mcp-settings-context"; export { ModelsProvider, useModels } from "./contexts/models-context"; - +export { useChatHistory } from "./hooks/use-chat-history"; // Hooks export { useMcpSettings } from "./hooks/use-mcp-settings"; export { useMcpToolsFetch } from "./hooks/use-mcp-tools-fetch";