diff --git a/.gitignore b/.gitignore index 02f86fcbd0..35e7397b01 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ internal .turbo .windsurfrules -CLAUDE.md \ No newline at end of file +CLAUDE.md +.cursor/ \ No newline at end of file diff --git a/apps/desktop/src/components/editor-area/index.tsx b/apps/desktop/src/components/editor-area/index.tsx index acd237d0d7..068ae49f4d 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -12,6 +12,7 @@ import { TemplateService } from "@/utils/template-service"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { commands as connectorCommands } from "@hypr/plugin-connector"; import { commands as dbCommands } from "@hypr/plugin-db"; +import { events as localLlmEvents } from "@hypr/plugin-local-llm"; import { commands as localLlmCommands } from "@hypr/plugin-local-llm"; import { commands as miscCommands } from "@hypr/plugin-misc"; import { commands as templateCommands, type Grammar } from "@hypr/plugin-template"; @@ -388,6 +389,21 @@ export function useEnhanceMutation({ const [isCancelled, setIsCancelled] = useState(false); const queryClient = useQueryClient(); + useEffect(() => { + let unlisten: () => void; + localLlmEvents.llmEvent.listen(({ payload }) => { + if (payload.progress) { + setProgress(payload.progress); + } + }).then((fn) => { + unlisten = fn; + }); + + return () => { + unlisten(); + }; + }, []); + // Extract H1 headers at component level (always available) const extractH1Headers = useCallback((htmlContent: string): string[] => { if (!htmlContent) { diff --git a/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx b/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx index cb1a374fd0..47408024ae 100644 --- a/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx +++ b/apps/desktop/src/components/right-panel/components/chat/chat-input.tsx @@ -153,7 +153,7 @@ export function ChatInput( const traverseNode = (node: any) => { if (node.type === "mention" || node.type === "mention-@") { - if (node.attrs) { + if (node.attrs && node.attrs.type !== "selection") { mentions.push({ id: node.attrs.id || node.attrs["data-id"], type: node.attrs.type || node.attrs["data-type"] || "note", diff --git a/apps/desktop/src/components/right-panel/components/chat/chat-message.tsx b/apps/desktop/src/components/right-panel/components/chat/chat-message.tsx deleted file mode 100644 index bf9782068c..0000000000 --- a/apps/desktop/src/components/right-panel/components/chat/chat-message.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { MessageContent } from "./message-content"; -import { Message } from "./types"; - -interface ChatMessageProps { - message: Message; - sessionTitle?: string; - hasEnhancedNote?: boolean; - onApplyMarkdown?: (markdownContent: string) => void; -} - -export function ChatMessage({ message, sessionTitle, hasEnhancedNote, onApplyMarkdown }: ChatMessageProps) { - if (message.isUser) { - return ( -
-
-
-
- -
-
- {/* Timestamp below the message */} -
- {message.timestamp.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} -
-
-
- ); - } - - return ( -
- -
- ); -} diff --git a/apps/desktop/src/components/right-panel/components/chat/chat-messages-view.tsx b/apps/desktop/src/components/right-panel/components/chat/chat-messages-view.tsx index db0c742984..f627dbcd93 100644 --- a/apps/desktop/src/components/right-panel/components/chat/chat-messages-view.tsx +++ b/apps/desktop/src/components/right-panel/components/chat/chat-messages-view.tsx @@ -1,14 +1,16 @@ +import type { UIMessage } from "@hypr/utils/ai"; import { useEffect, useRef, useState } from "react"; -import { ChatMessage } from "./chat-message"; -import { Message } from "./types"; +import { UIMessageComponent } from "./ui-message"; interface ChatMessagesViewProps { - messages: Message[]; + messages: UIMessage[]; sessionTitle?: string; hasEnhancedNote?: boolean; onApplyMarkdown?: (markdownContent: string) => void; - isGenerating?: boolean; - isStreamingText?: boolean; + isSubmitted?: boolean; + isStreaming?: boolean; + isReady?: boolean; + isError?: boolean; } function ThinkingIndicator() { @@ -30,7 +32,7 @@ function ThinkingIndicator() { } `} -
+
Thinking . . @@ -41,27 +43,45 @@ function ThinkingIndicator() { } export function ChatMessagesView( - { messages, sessionTitle, hasEnhancedNote, onApplyMarkdown, isGenerating, isStreamingText }: ChatMessagesViewProps, + { messages, sessionTitle, hasEnhancedNote, onApplyMarkdown, isSubmitted, isStreaming, isReady, isError }: + ChatMessagesViewProps, ) { const messagesEndRef = useRef(null); const [showThinking, setShowThinking] = useState(false); const thinkingTimeoutRef = useRef(null); const shouldShowThinking = () => { - if (!isGenerating) { - return false; - } - - if (messages.length === 0) { + // Show thinking when request is submitted but not yet streaming + if (isSubmitted) { return true; } - const lastMessage = messages[messages.length - 1]; - if (lastMessage.isUser) { - return true; + // Check if we're in transition between parts (text → tool or tool → text) + if (isStreaming && messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + if (lastMessage.role === "assistant" && lastMessage.parts) { + const lastPart = lastMessage.parts[lastMessage.parts.length - 1]; + + // Text part finished but still streaming (tool coming) + if (lastPart?.type === "text" && !(lastPart as any).state) { + return true; + } + + // Tool finished but still streaming (more text/tools coming) + if (lastPart?.type?.startsWith("tool-") || lastPart?.type === "dynamic-tool") { + const toolPart = lastPart as any; + if ( + toolPart.state === "output-available" + || toolPart.state === "output-error" + ) { + return true; + } + } + } } - if (!lastMessage.isUser && !isStreamingText) { + // Fallback for other transition states + if (!isReady && !isStreaming && !isError) { return true; } @@ -89,16 +109,16 @@ export function ChatMessagesView( clearTimeout(thinkingTimeoutRef.current); } }; - }, [isGenerating, isStreamingText, messages]); + }, [isSubmitted, isStreaming, isReady, isError, messages]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, showThinking]); return ( -
+
{messages.map((message) => ( - {/* Flat card with no shadow */} -
+
{/* Grey header section - Made thinner with py-1 */}
diff --git a/apps/desktop/src/components/right-panel/components/chat/message-content.tsx b/apps/desktop/src/components/right-panel/components/chat/message-content.tsx deleted file mode 100644 index 2b11a66fed..0000000000 --- a/apps/desktop/src/components/right-panel/components/chat/message-content.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { commands as miscCommands } from "@hypr/plugin-misc"; -import Renderer from "@hypr/tiptap/renderer"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hypr/ui/components/ui/accordion"; -import { PencilRuler } from "lucide-react"; -import { useEffect, useState } from "react"; -import { MarkdownCard } from "./markdown-card"; -import { Message } from "./types"; - -interface MessageContentProps { - message: Message; - sessionTitle?: string; - hasEnhancedNote?: boolean; - onApplyMarkdown?: (markdownContent: string) => void; -} - -function ToolDetailsRenderer({ details }: { details: any }) { - if (!details) { - return ( -
- No details available... -
- ); - } - - return ( -
-
-        {typeof details === 'object' ? JSON.stringify(details, null, 2) : String(details)}
-      
-
- ); -} - -function MarkdownText({ content, htmlContent }: { content: string; htmlContent?: string }) { - const [displayHtml, setDisplayHtml] = useState(""); - - useEffect(() => { - const processContent = async () => { - // If we have HTML content with mentions, use it directly - if (htmlContent) { - setDisplayHtml(htmlContent); - return; - } - - // Otherwise, convert markdown as usual - try { - let html = await miscCommands.opinionatedMdToHtml(content); - - html = html - .replace(/

\s*<\/p>/g, "") - .replace(/

\u00A0<\/p>/g, "") - .replace(/

 <\/p>/g, "") - .replace(/

\s+<\/p>/g, "") - .replace(/

<\/p>/g, "") - .trim(); - - setDisplayHtml(html); - } catch (error) { - console.error("Failed to convert markdown:", error); - setDisplayHtml(content); - } - }; - - if (content.trim() || htmlContent) { - processContent(); - } - }, [content, htmlContent]); - - return ( - <> - -

- -
- - ); -} - -export function MessageContent({ message, sessionTitle, hasEnhancedNote, onApplyMarkdown }: MessageContentProps) { - let htmlContent: string | undefined; - if (message.isUser && message.toolDetails) { - try { - const details = typeof message.toolDetails === "string" - ? JSON.parse(message.toolDetails) - : message.toolDetails; - htmlContent = details.htmlContent; - } catch (error) { - console.error("Failed to parse HTML content from toolDetails:", error); - } - } - if (message.type === "tool-start") { - const hasToolDetails = message.toolDetails; - - if (hasToolDetails) { - return ( -
- - - -
- - - Called tool: {message.content} - -
-
- - - -
-
-
- ); - } else { - return ( -
-
- - - Called tool: {message.content} - -
-
- ); - } - } - - if (message.type === "tool-result") { - return ( -
-
- - - {message.content} - -
-
- ); - } - - if (message.type === "tool-error") { - return ( -
-
- - - Tool Error: {message.content} - -
-
- ); - } - - if (!message.parts || message.parts.length === 0) { - return ; - } - - return ( -
- {message.parts.map((part, index) => ( -
- {part.type === "text" - ? - : ( - - )} -
- ))} -
- ); -} diff --git a/apps/desktop/src/components/right-panel/components/chat/ui-message.tsx b/apps/desktop/src/components/right-panel/components/chat/ui-message.tsx new file mode 100644 index 0000000000..7c43ecbfe4 --- /dev/null +++ b/apps/desktop/src/components/right-panel/components/chat/ui-message.tsx @@ -0,0 +1,473 @@ +import { commands as miscCommands } from "@hypr/plugin-misc"; +import Renderer from "@hypr/tiptap/renderer"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hypr/ui/components/ui/accordion"; +import type { UIMessage } from "@hypr/utils/ai"; +import { AlertCircle, Check, Loader2 } from "lucide-react"; +import { type FC, useEffect, useState } from "react"; +import { parseMarkdownBlocks } from "../../utils/markdown-parser"; +import { MarkdownCard } from "./markdown-card"; + +interface UIMessageComponentProps { + message: UIMessage; + sessionTitle?: string; + hasEnhancedNote?: boolean; + onApplyMarkdown?: (content: string) => void; +} + +// Component for rendering markdown/HTML content +const TextContent: FC<{ content: string; isHtml?: boolean }> = ({ content, isHtml }) => { + const [displayHtml, setDisplayHtml] = useState(""); + + useEffect(() => { + const processContent = async () => { + if (isHtml) { + setDisplayHtml(content); + return; + } + + // Convert markdown to HTML + try { + let html = await miscCommands.opinionatedMdToHtml(content); + + // Clean up empty paragraphs like reference code + html = html + .replace(/

\s*<\/p>/g, "") + .replace(/

\u00A0<\/p>/g, "") + .replace(/

 <\/p>/g, "") + .replace(/

\s+<\/p>/g, "") + .replace(/

<\/p>/g, "") + .trim(); + + setDisplayHtml(html); + } catch (error) { + console.error("Failed to convert markdown:", error); + setDisplayHtml(content); + } + }; + + if (content.trim()) { + processContent(); + } + }, [content, isHtml]); + + return ( + <> + +

+ +
+ + ); +}; + +export const UIMessageComponent: FC = ({ + message, + sessionTitle, + hasEnhancedNote, + onApplyMarkdown, +}) => { + const isUser = message.role === "user"; + + // Extract text content from parts + const getTextContent = () => { + if (!message.parts || message.parts.length === 0) { + return ""; + } + + const textParts = message.parts + .filter(part => part.type === "text") + .map(part => part.text || "") + .join(""); + + return textParts; + }; + + // User message styling + if (isUser) { + // Check for HTML content in metadata (for mentions/selections) + const htmlContent = (message.metadata as any)?.htmlContent; + const textContent = getTextContent(); + + return ( +
+
+
+
+ +
+
+ {(message as any).createdAt && ( +
+ {new Date((message as any).createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ )} +
+
+ ); + } + + // Assistant message - render parts + return ( +
+ {message.parts?.map((part, index) => { + // Text content - parse for markdown blocks + if (part.type === "text" && part.text) { + const parsedParts = parseMarkdownBlocks(part.text); + + return ( +
+ {parsedParts.map((parsedPart, pIndex) => { + if (parsedPart.type === "markdown") { + return ( + + ); + } + // Regular text + return ( +
+ +
+ ); + })} +
+ ); + } + + // Handle tool parts - check for dynamic tools or specific tool types + if (part.type === "dynamic-tool" || part.type?.startsWith("tool-")) { + const toolPart = part as any; + + // Extract tool name - either from toolName field (dynamic) or from type (specific) + const toolName = toolPart.toolName || part.type.replace("tool-", ""); + + // Tool execution start (input streaming or available) + if ( + (toolPart.state === "input-streaming" || toolPart.state === "input-available") + ) { + return ( +
+ + + +
+ + + {toolPart.state === "input-streaming" ? "Calling" : "Called"} tool: {toolName} + +
+
+ + {toolPart.input && ( +
+
+ Input: +
+
+                            {JSON.stringify(toolPart.input, null, 2)}
+                          
+
+ )} +
+
+
+
+ ); + } + + // Tool completion (output available) + if (toolPart.state === "output-available") { + return ( +
+ + + +
+ + + Tool finished: {toolName} + +
+
+ + {/* Show input */} + {toolPart.input && ( +
+
+ Input: +
+
+                            {JSON.stringify(toolPart.input, null, 2)}
+                          
+
+ )} + + {/* Show output */} + {toolPart.output && ( +
+
+ Output: +
+
+                            {JSON.stringify(toolPart.output, null, 2)}
+                          
+
+ )} +
+
+
+
+ ); + } + + // Tool error + if (toolPart.state === "output-error") { + return ( +
+
+ + + Tool error: {toolName} + +
+ {toolPart.errorText && ( +
+ {toolPart.errorText} +
+ )} +
+ ); + } + } + + return null; + })} +
+ ); +}; diff --git a/apps/desktop/src/components/right-panel/hooks/useChat2.ts b/apps/desktop/src/components/right-panel/hooks/useChat2.ts new file mode 100644 index 0000000000..875cae26ca --- /dev/null +++ b/apps/desktop/src/components/right-panel/hooks/useChat2.ts @@ -0,0 +1,203 @@ +import { useLicense } from "@/hooks/use-license"; +import { commands as dbCommands } from "@hypr/plugin-db"; +import { useChat } from "@hypr/utils/ai"; +import { useCallback, useEffect, useRef } from "react"; +import { CustomChatTransport } from "../utils/chat-transport"; + +interface UseChat2Props { + sessionId: string | null; + userId: string | null; + conversationId: string | null; + sessionData?: any; + selectionData?: any; + sessions?: any; + onError?: (error: Error) => void; +} + +export function useChat2({ + sessionId, + userId, + conversationId, + sessionData, + selectionData, + sessions, + onError, +}: UseChat2Props) { + const { getLicense } = useLicense(); + const transportRef = useRef(null); + const conversationIdRef = useRef(conversationId); + + useEffect(() => { + conversationIdRef.current = conversationId; + }, [conversationId]); + + if (!transportRef.current) { + transportRef.current = new CustomChatTransport({ + sessionId, + userId, + sessionData, + selectionData, + sessions, + getLicense: getLicense as any, + }); + } + + useEffect(() => { + if (transportRef.current) { + transportRef.current.updateOptions({ + sessionId, + userId, + sessionData, + selectionData, + sessions, + getLicense: getLicense as any, + }); + } + }, [sessionId, userId, sessionData, selectionData, sessions, getLicense]); + + useEffect(() => { + return () => { + if (transportRef.current) { + transportRef.current.cleanup(); + } + }; + }, []); + + const { + messages, + sendMessage: sendAIMessage, + stop, + status, + error, + addToolResult, + setMessages, + } = useChat({ + transport: transportRef.current, + messages: [], + id: sessionId || "default", + onError: async (err: any) => { + const errorMessage = { + id: crypto.randomUUID(), + role: "assistant" as const, + parts: [{ + type: "text" as const, + text: `An error occurred: ${err.message}`, + }] as any, + metadata: { + isError: true, + errorDetails: err, + }, + } as const; + setMessages((prev: any) => [...prev, errorMessage]); + stop(); + onError?.(err); + }, + onFinish: async ({ message }: { message: any }) => { + const currentConvId = conversationIdRef.current; + if (currentConvId && message && message.role === "assistant") { + try { + await dbCommands.createMessageV2({ + id: message.id, + conversation_id: currentConvId, + role: "assistant" as any, + parts: JSON.stringify(message.parts || []), + metadata: JSON.stringify(message.metadata || {}), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + } catch (error) { + console.error("Failed to save assistant message:", error); + } + } else { + console.warn("Skipping save - missing data:", { conversationId: currentConvId, messageRole: message?.role }); + } + }, + }); + + const sendMessage = useCallback( + async ( + content: string, + options?: { + mentionedContent?: Array<{ id: string; type: string; label: string }>; + selectionData?: any; + htmlContent?: string; + conversationId?: string; + }, + ) => { + const metadata = { + mentions: options?.mentionedContent, + selectionData: options?.selectionData, + htmlContent: options?.htmlContent, + }; + + const convId = options?.conversationId || conversationId; + + if (!convId || !content.trim()) { + return; + } + + if (transportRef.current) { + transportRef.current.updateOptions({ + mentionedContent: options?.mentionedContent, + selectionData: options?.selectionData, + sessions: sessions || {}, + }); + } + + // Small delay to ensure options are updated before tools are loaded + await new Promise(resolve => setTimeout(resolve, 10)); + + try { + const userMessageId = crypto.randomUUID(); + await dbCommands.createMessageV2({ + id: userMessageId, + conversation_id: convId, + role: "user" as any, + parts: JSON.stringify([{ type: "text", text: content }]), + metadata: JSON.stringify(metadata), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + sendAIMessage({ + id: userMessageId, + role: "user", + parts: [{ type: "text", text: content }], + metadata, + }); + } catch (error) { + console.error("Failed to send message:", error); + onError?.(error as Error); + } + }, + [sendAIMessage, conversationId], + ); + + const updateMessageParts = useCallback( + async (messageId: string, parts: any[]) => { + if (conversationId) { + try { + await dbCommands.updateMessageV2Parts( + messageId, + JSON.stringify(parts), + ); + } catch (error) { + console.error("Failed to update message parts:", error); + } + } + }, + [conversationId], + ); + + return { + messages, + stop, + setMessages, + isGenerating: status === "streaming" || status === "submitted", + error, + addToolResult, + sendMessage, + updateMessageParts, + status, + }; +} diff --git a/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts b/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts deleted file mode 100644 index 8759f6c4d0..0000000000 --- a/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { showProGateModal } from "@/components/pro-gate-modal/service"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { SelectionData } from "@/contexts/right-panel"; - -import { useLicense } from "@/hooks/use-license"; -import { commands as analyticsCommands } from "@hypr/plugin-analytics"; -import { commands as connectorCommands } from "@hypr/plugin-connector"; -import { commands as dbCommands } from "@hypr/plugin-db"; -import { commands as mcpCommands } from "@hypr/plugin-mcp"; -import { commands as miscCommands } from "@hypr/plugin-misc"; -import { fetch as tauriFetch } from "@hypr/utils"; -import { - dynamicTool, - experimental_createMCPClient, - modelProvider, - smoothStream, - stepCountIs, - streamText, - tool, -} from "@hypr/utils/ai"; -import { useSessions } from "@hypr/utils/contexts"; -import { useQueryClient } from "@tanstack/react-query"; -import { getLicenseKey } from "tauri-plugin-keygen-api"; -import { z } from "zod"; -import type { ActiveEntityInfo, Message } from "../types/chat-types"; -import { prepareMessageHistory } from "../utils/chat-utils"; -import { parseMarkdownBlocks } from "../utils/markdown-parser"; -import { buildVercelToolsFromMcp } from "../utils/mcp-http-wrapper"; -import { createEditEnhancedNoteTool } from "../utils/tools/edit_enhanced_note"; -import { createSearchSessionDateRangeTool } from "../utils/tools/search_session_date_range"; -import { createSearchSessionTool } from "../utils/tools/search_session_multi_keywords"; - -interface UseChatLogicProps { - sessionId: string | null; - userId: string | null; - activeEntity: ActiveEntityInfo | null; - messages: Message[]; - inputValue: string; - hasChatStarted: boolean; - setMessages: (messages: Message[] | ((prev: Message[]) => Message[])) => void; - setInputValue: (value: string) => void; - setHasChatStarted: (started: boolean) => void; - getChatGroupId: () => Promise; - sessionData: any; - chatInputRef: React.RefObject; - llmConnectionQuery: any; -} - -export function useChatLogic({ - sessionId, - userId, - activeEntity, - messages, - inputValue, - hasChatStarted, - setMessages, - setInputValue, - setHasChatStarted, - getChatGroupId, - sessionData, - chatInputRef, - llmConnectionQuery, -}: UseChatLogicProps) { - const [isGenerating, setIsGenerating] = useState(false); - const [isStreamingText, setIsStreamingText] = useState(false); - const isGeneratingRef = useRef(false); - const abortControllerRef = useRef(null); - const sessions = useSessions((state) => state.sessions); - const { getLicense } = useLicense(); - const queryClient = useQueryClient(); - - // Reset generation state and abort ongoing streams when session changes - useEffect(() => { - // Abort any ongoing generation when session changes - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - - // Reset generation state for new session - setIsGenerating(false); - setIsStreamingText(false); - isGeneratingRef.current = false; - }, [sessionId]); - - const handleApplyMarkdown = async (markdownContent: string) => { - if (!sessionId) { - console.error("No session ID available"); - return; - } - - const sessionStore = sessions[sessionId]; - if (!sessionStore) { - console.error("Session not found in store"); - return; - } - - try { - const html = await miscCommands.opinionatedMdToHtml(markdownContent); - - const { session, showRaw } = sessionStore.getState(); - - const hasEnhancedNote = !!session.enhanced_memo_html; - - if (!hasEnhancedNote) { - sessionStore.getState().updateRawNote(html); - } else { - if (showRaw) { - sessionStore.getState().updateRawNote(html); - } else { - sessionStore.getState().updateEnhancedNote(html); - } - } - } catch (error) { - console.error("Failed to apply markdown content:", error); - } - }; - - const processUserMessage = async ( - content: string, - analyticsEvent: string, - mentionedContent?: Array<{ id: string; type: string; label: string }>, - selectionData?: SelectionData, - htmlContent?: string, - ) => { - if (!content.trim() || isGenerating) { - return; - } - - const userMessageCount = messages.filter(msg => msg.isUser).length; - - if (userMessageCount >= 4 && !getLicense.data?.valid) { - if (userId) { - await analyticsCommands.event({ - event: "pro_license_required_chat", - distinct_id: userId, - }); - } - await showProGateModal("chat"); - return; - } - - if (userId) { - await analyticsCommands.event({ - event: analyticsEvent, - distinct_id: userId, - }); - } - - if (!hasChatStarted && activeEntity) { - setHasChatStarted(true); - } - - setIsGenerating(true); - isGeneratingRef.current = true; - - const groupId = await getChatGroupId(); - - // Prepare toolDetails before creating the message - let toolDetails = null; - if (htmlContent && (mentionedContent?.length || selectionData)) { - toolDetails = { htmlContent }; - } - - const userMessage: Message = { - id: crypto.randomUUID(), - content: content, - isUser: true, - timestamp: new Date(), - type: "text-delta", - toolDetails: toolDetails, // Include toolDetails in the message object - }; - - setMessages((prev) => [...prev, userMessage]); - setInputValue(""); - - await dbCommands.upsertChatMessage({ - id: userMessage.id, - group_id: groupId, - created_at: userMessage.timestamp.toISOString(), - role: "User", - content: userMessage.content.trim(), - type: "text-delta", - tool_details: toolDetails ? JSON.stringify(toolDetails) : null, - }); - - const aiMessageId = crypto.randomUUID(); - - try { - const provider = await modelProvider(); - const model = provider.languageModel("defaultModel"); - - await queryClient.invalidateQueries({ queryKey: ["llm-connection"] }); - await new Promise(resolve => setTimeout(resolve, 100)); - - const llmConnection = await connectorCommands.getLlmConnection(); - const { type } = llmConnection; - const apiBase = llmConnection.connection?.api_base; - - let newMcpTools: Record = {}; - let hyprMcpTools: Record = {}; - let mcpToolsArray: any[] = []; - const allMcpClients: any[] = []; - let hyprMcpClient: Client | null = null; - - const shouldUseTools = model.modelId === "gpt-4.1" || model.modelId === "openai/gpt-4.1" - || model.modelId === "anthropic/claude-sonnet-4" - || model.modelId === "openai/gpt-4o" - || model.modelId === "gpt-4o" - || apiBase?.includes("pro.hyprnote.com") - || model.modelId === "openai/gpt-5"; - - if (shouldUseTools) { - const mcpServers = await mcpCommands.getServers(); - const enabledSevers = mcpServers.filter((server) => server.enabled); - - if (apiBase?.includes("pro.hyprnote.com") && getLicense.data?.valid) { - try { - const licenseKey = await getLicenseKey(); - - const transport = new StreamableHTTPClientTransport( - new URL("https://pro.hyprnote.com/mcp"), - { - fetch: tauriFetch, - requestInit: { - headers: { - "x-hyprnote-license-key": licenseKey || "", - }, - }, - }, - ); - hyprMcpClient = new Client({ - name: "hyprmcp", - version: "0.1.0", - }); - - await hyprMcpClient.connect(transport); - - hyprMcpTools = await buildVercelToolsFromMcp(hyprMcpClient); - } catch (error) { - console.error("Error creating and adding hyprmcp client:", error); - } - } - - for (const server of enabledSevers) { - try { - const mcpClient = await experimental_createMCPClient({ - transport: { - type: "sse", - url: server.url, - ...(server.headerKey && server.headerValue && { - headers: { - [server.headerKey]: server.headerValue, - }, - }), - onerror: (error) => { - console.log("mcp client error: ", error); - }, - onclose: () => { - console.log("mcp client closed"); - }, - }, - }); - allMcpClients.push(mcpClient); - - const tools = await mcpClient.tools(); - for (const [toolName, tool] of Object.entries(tools as Record)) { - newMcpTools[toolName] = dynamicTool({ - description: tool.description, - inputSchema: tool.inputSchema || z.any(), - execute: tool.execute, - }); - } - } catch (error) { - console.error("Error creating MCP client:", error); - } - } - - mcpToolsArray = Object.keys(newMcpTools).length > 0 - ? Object.entries(newMcpTools).map(([name, tool]) => ({ - name, - description: tool.description || `Tool: ${name}`, - inputSchema: tool.inputSchema || "No input schema provided", - })) - : []; - - for (const [toolKey, tool] of Object.entries(hyprMcpTools)) { - mcpToolsArray.push({ - name: toolKey, - description: tool.description || `Tool: ${tool.name}`, - inputSchema: tool.inputSchema || "No input schema provided", - }); - } - } - - // Create tools using the refactored tool factories - const searchTool = createSearchSessionTool(userId); - const editEnhancedNoteTool = createEditEnhancedNoteTool({ - sessionId, - sessions, - selectionData, - }); - const searchSessionDateRangeTool = createSearchSessionDateRangeTool(userId); - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - const baseTools = { - ...(selectionData && { edit_enhanced_note: editEnhancedNoteTool }), - search_sessions_date_range: searchSessionDateRangeTool, - search_sessions_multi_keywords: searchTool, - }; - - const { fullStream } = streamText({ - model, - messages: await prepareMessageHistory( - messages, - content, - mentionedContent, - model.modelId, - mcpToolsArray, - sessionData, - sessionId, - userId, - apiBase, - selectionData, // Pass selectionData to prepareMessageHistory - ), - stopWhen: stepCountIs(5), - tools: { - ...(shouldUseTools && { ...hyprMcpTools, ...newMcpTools }), - ...(shouldUseTools && baseTools), - ...(type === "HyprLocal" && { progress_update: tool({ inputSchema: z.any() }) }), - }, - onError: (error) => { - console.error("On Error Catch:", error); - setIsGenerating(false); - isGeneratingRef.current = false; - throw error; - }, - onFinish: () => { - for (const client of allMcpClients) { - client.close(); - } - // close hyprmcp client - hyprMcpClient?.close(); - }, - abortSignal: abortController.signal, - experimental_transform: smoothStream({ - delayInMs: 30, - chunking: "word", - }), - }); - - let aiResponse = ""; - let didInitializeAiResponse = false; - let currentAiTextMessageId: string | null = null; - let lastChunkType: string | null = null; - - for await (const chunk of fullStream) { - if (lastChunkType === "text-delta" && chunk.type !== "text-delta" && chunk.type !== "finish-step") { - setIsStreamingText(false); // Text streaming has stopped, more content coming - - await new Promise(resolve => setTimeout(resolve, 50)); - } - - if (chunk.type === "text-delta") { - setIsStreamingText(true); - - setMessages((prev) => { - const lastMessage = prev[prev.length - 1]; - - if (didInitializeAiResponse && lastMessage && lastMessage.type === "text-delta") { - // Same type (text) -> update existing message - - aiResponse += chunk.text; - currentAiTextMessageId = lastMessage.id; - const parts = parseMarkdownBlocks(aiResponse); - - return prev.map(msg => - msg.id === lastMessage.id - ? { ...msg, content: aiResponse, parts, type: "text-delta" } - : msg - ); - } else { - if (!didInitializeAiResponse) { - aiResponse = ""; - didInitializeAiResponse = true; - } - - aiResponse += chunk.text; - const parts = parseMarkdownBlocks(aiResponse); - - // Different type -> create new message - const newTextMessage: Message = { - id: crypto.randomUUID(), - content: aiResponse, - isUser: false, - timestamp: new Date(), - type: "text-delta", - parts, - }; - - currentAiTextMessageId = newTextMessage.id; - return [...prev, newTextMessage]; - } - }); - } - - if (chunk.type === "tool-call" && !(chunk.toolName === "progress_update" && type === "HyprLocal")) { - // Save accumulated AI text before processing tool - - if (currentAiTextMessageId && aiResponse.trim()) { - const saveAiText = async () => { - try { - await dbCommands.upsertChatMessage({ - id: currentAiTextMessageId!, - group_id: groupId, - created_at: new Date().toISOString(), - role: "Assistant", - type: "text-delta", - content: aiResponse.trim(), - tool_details: null, - }); - } catch (error) { - console.error("Failed to save AI text:", error); - } - }; - saveAiText(); - currentAiTextMessageId = null; // Reset - } - - didInitializeAiResponse = false; - - const toolStartMessage: Message = { - id: crypto.randomUUID(), - content: `${chunk.toolName}`, - isUser: false, - timestamp: new Date(), - type: "tool-start", - toolDetails: chunk.input, - }; - setMessages((prev) => [...prev, toolStartMessage]); - - // save message to db right away - await dbCommands.upsertChatMessage({ - id: toolStartMessage.id, - group_id: groupId, - created_at: toolStartMessage.timestamp.toISOString(), - role: "Assistant", - content: toolStartMessage.content, - type: "tool-start", - tool_details: JSON.stringify(chunk.input), - }); - - // log if user is using tools in chat - analyticsCommands.event({ - event: "chat_tool_call", - distinct_id: userId || "", - }); - } - - if (chunk.type === "tool-result" && !(chunk.toolName === "progress_update" && type === "HyprLocal")) { - didInitializeAiResponse = false; - - const toolResultMessage: Message = { - id: crypto.randomUUID(), - content: `Tool finished: ${chunk.toolName}`, - isUser: false, - timestamp: new Date(), - type: "tool-result", - }; - - setMessages((prev) => [...prev, toolResultMessage]); - - await dbCommands.upsertChatMessage({ - id: toolResultMessage.id, - group_id: groupId, - created_at: toolResultMessage.timestamp.toISOString(), - role: "Assistant", - content: toolResultMessage.content, - type: "tool-result", - tool_details: null, - }); - } - - if (chunk.type === "tool-error" && !(chunk.toolName === "progress_update" && type === "HyprLocal")) { - didInitializeAiResponse = false; - const toolErrorMessage: Message = { - id: crypto.randomUUID(), - content: `Tool error: ${chunk.error}`, - isUser: false, - timestamp: new Date(), - type: "tool-error", - }; - setMessages((prev) => [...prev, toolErrorMessage]); - - await dbCommands.upsertChatMessage({ - id: toolErrorMessage.id, - group_id: groupId, - created_at: toolErrorMessage.timestamp.toISOString(), - role: "Assistant", - content: toolErrorMessage.content, - type: "tool-error", - tool_details: null, - }); - } - - lastChunkType = chunk.type; - } - - if (currentAiTextMessageId && aiResponse.trim()) { - await dbCommands.upsertChatMessage({ - id: currentAiTextMessageId, - group_id: groupId, - created_at: new Date().toISOString(), - role: "Assistant", - type: "text-delta", - content: aiResponse.trim(), - tool_details: null, - }); - } - - setIsGenerating(false); - setIsStreamingText(false); - isGeneratingRef.current = false; - abortControllerRef.current = null; // Clear the abort controller on successful completion - } catch (error) { - console.error(error); - - let errorMsg = "Unknown error"; - if (typeof error === "string") { - errorMsg = error; - } else if (error instanceof Error) { - errorMsg = error.message || error.name || "Unknown error"; - } else if ((error as any)?.error) { - errorMsg = (error as any).error; - } else if ((error as any)?.message) { - errorMsg = (error as any).message; - } - - let finalErrorMessage = ""; - - if (String(errorMsg).includes("too large")) { - finalErrorMessage = - "Sorry, I encountered an error. Please try again. Your transcript or meeting notes might be too large. Please try again with a smaller transcript or meeting notes." - + "\n\n" + errorMsg; - } else if (String(errorMsg).includes("Request cancelled") || String(errorMsg).includes("Request canceled")) { - finalErrorMessage = "Request was cancelled mid-stream. Try again with a different message."; - } else { - finalErrorMessage = "Sorry, I encountered an error. Please try again. " + "\n\n" + errorMsg; - } - - setIsGenerating(false); - setIsStreamingText(false); - isGeneratingRef.current = false; - abortControllerRef.current = null; // Clear the abort controller on error - - // Create error message - const errorMessage: Message = { - id: aiMessageId, - content: finalErrorMessage, - isUser: false, - timestamp: new Date(), - type: "text-delta", - }; - - setMessages((prev) => [...prev, errorMessage]); - - await dbCommands.upsertChatMessage({ - id: aiMessageId, - group_id: groupId, - created_at: new Date().toISOString(), - role: "Assistant", - content: finalErrorMessage, - type: "text-delta", - tool_details: null, - }); - } - }; - - const handleSubmit = async ( - mentionedContent?: Array<{ id: string; type: string; label: string }>, - selectionData?: SelectionData, - htmlContent?: string, - ) => { - await processUserMessage(inputValue, "chat_message_sent", mentionedContent, selectionData, htmlContent); - }; - - const handleQuickAction = async (prompt: string) => { - await processUserMessage(prompt, "chat_quickaction_sent", undefined, undefined); - - if (chatInputRef.current) { - chatInputRef.current.focus(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - - const handleStop = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); - - return { - isGenerating, - isStreamingText, - handleSubmit, - handleQuickAction, - handleApplyMarkdown, - handleKeyDown, - handleStop, - }; -} diff --git a/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts b/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts deleted file mode 100644 index a1de3c15f7..0000000000 --- a/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useEffect } from "react"; - -import { commands as dbCommands } from "@hypr/plugin-db"; -import type { Message } from "../types/chat-types"; -import { parseMarkdownBlocks } from "../utils/markdown-parser"; - -interface UseChatQueriesProps { - sessionId: string | null; - userId: string | null; - currentChatGroupId: string | null; - setCurrentChatGroupId: (id: string | null) => void; - setMessages: (messages: Message[]) => void; - setHasChatStarted: (started: boolean) => void; - isGenerating?: boolean; - prevIsGenerating?: React.MutableRefObject; -} - -export function useChatQueries({ - sessionId, - userId, - currentChatGroupId, - setCurrentChatGroupId, - setMessages, - setHasChatStarted, - isGenerating, - prevIsGenerating, -}: UseChatQueriesProps) { - const chatGroupsQuery = useQuery({ - enabled: !!sessionId && !!userId, - queryKey: ["chat-groups", sessionId], - queryFn: async () => { - if (!sessionId || !userId) { - return []; - } - const groups = await dbCommands.listChatGroups(sessionId); - - const groupsWithFirstMessage = await Promise.all( - groups.map(async (group) => { - const messages = await dbCommands.listChatMessages(group.id); - const firstUserMessage = messages.find(msg => msg.role === "User"); - - // Find the most recent message timestamp in this group - const mostRecentMessageTimestamp = messages.length > 0 - ? Math.max(...messages.map(msg => new Date(msg.created_at).getTime())) - : new Date(group.created_at).getTime(); // Fallback to group creation time if no messages - - return { - ...group, - firstMessage: firstUserMessage?.content || "", - mostRecentMessageTimestamp, - }; - }), - ); - - return groupsWithFirstMessage; - }, - }); - - useEffect(() => { - if (chatGroupsQuery.data && chatGroupsQuery.data.length > 0) { - // Sort by most recent message timestamp instead of group creation time - const latestGroup = chatGroupsQuery.data.sort((a, b) => - b.mostRecentMessageTimestamp - a.mostRecentMessageTimestamp - )[0]; - setCurrentChatGroupId(latestGroup.id); - } else if (chatGroupsQuery.data && chatGroupsQuery.data.length === 0) { - // No groups exist for this session - setCurrentChatGroupId(null); - } - }, [chatGroupsQuery.data, sessionId, setCurrentChatGroupId]); - - const chatMessagesQuery = useQuery({ - enabled: !!currentChatGroupId, - queryKey: ["chat-messages", currentChatGroupId], - queryFn: async () => { - if (!currentChatGroupId) { - return []; - } - - const dbMessages = await dbCommands.listChatMessages(currentChatGroupId); - return dbMessages.map(msg => { - // Parse tool_details for all messages - let parsedToolDetails: any = undefined; - if (msg.tool_details) { - try { - parsedToolDetails = JSON.parse(msg.tool_details); - } catch (error) { - console.error("Failed to parse tool_details:", msg.id, error); - } - } - - return { - id: msg.id, - content: msg.content, - isUser: msg.role === "User", - timestamp: new Date(msg.created_at), - type: msg.type || "text-delta", - parts: msg.role === "Assistant" ? parseMarkdownBlocks(msg.content) : undefined, - toolDetails: parsedToolDetails, - }; - }); - }, - }); - - useEffect(() => { - const justFinishedGenerating = prevIsGenerating && prevIsGenerating.current === true && isGenerating === false; - if (prevIsGenerating) { - prevIsGenerating.current = isGenerating || false; - } - - if (chatMessagesQuery.data) { - if (!isGenerating && !justFinishedGenerating) { - // Safe to sync from database - setMessages(chatMessagesQuery.data); - setHasChatStarted(chatMessagesQuery.data.length > 0); - } else { - // Currently generating - DON'T override local state - console.log("Skipping DB sync - currently generating"); - } - } - }, [chatMessagesQuery.data, isGenerating, setMessages, setHasChatStarted, prevIsGenerating]); - - const sessionData = useQuery({ - enabled: !!sessionId, - queryKey: ["session", "chat-context", sessionId], - queryFn: async () => { - if (!sessionId) { - return null; - } - - const session = await dbCommands.getSession({ id: sessionId }); - if (!session) { - return null; - } - - return { - title: session.title || "", - rawContent: session.raw_memo_html || "", - enhancedContent: session.enhanced_memo_html, - preMeetingContent: session.pre_meeting_memo_html, - words: session.words || [], - }; - }, - }); - - const getChatGroupId = async (): Promise => { - if (!sessionId || !userId) { - throw new Error("No session or user"); - } - - if (currentChatGroupId) { - return currentChatGroupId; - } - - const chatGroup = await dbCommands.createChatGroup({ - id: crypto.randomUUID(), - session_id: sessionId, - user_id: userId, - name: null, - created_at: new Date().toISOString(), - }); - - setCurrentChatGroupId(chatGroup.id); - chatGroupsQuery.refetch(); - return chatGroup.id; - }; - - return { - chatGroupsQuery, - chatMessagesQuery, - sessionData, - getChatGroupId, - }; -} diff --git a/apps/desktop/src/components/right-panel/hooks/useChatQueries2.ts b/apps/desktop/src/components/right-panel/hooks/useChatQueries2.ts new file mode 100644 index 0000000000..539e73ff5c --- /dev/null +++ b/apps/desktop/src/components/right-panel/hooks/useChatQueries2.ts @@ -0,0 +1,175 @@ +import { commands as dbCommands } from "@hypr/plugin-db"; +import type { UIMessage } from "@hypr/utils/ai"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; + +interface UseChatQueries2Props { + sessionId: string | null; + userId: string | null; + currentConversationId: string | null; + setCurrentConversationId: (id: string | null) => void; + setMessages: (messages: UIMessage[]) => void; + isGenerating?: boolean; +} + +export function useChatQueries2({ + sessionId, + userId, + currentConversationId, + setCurrentConversationId, + setMessages, + isGenerating, +}: UseChatQueries2Props) { + const conversationsQuery = useQuery({ + enabled: !!sessionId && !!userId, + queryKey: ["conversations", sessionId], + queryFn: async () => { + if (!sessionId || !userId) { + return []; + } + const conversations = await dbCommands.listConversations(sessionId); + + const conversationsWithPreview = await Promise.all( + conversations.map(async (conv) => { + const messages = await dbCommands.listMessagesV2(conv.id); + const firstUserMessage = messages.find(msg => msg.role === "user"); + + const mostRecentTimestamp = messages.length > 0 + ? Math.max(...messages.map(msg => new Date(msg.created_at).getTime())) + : new Date(conv.created_at).getTime(); + + return { + ...conv, + firstMessage: firstUserMessage ? (JSON.parse(firstUserMessage.parts)[0]?.text || "") : "", + mostRecentTimestamp, + }; + }), + ); + + return conversationsWithPreview; + }, + }); + + useEffect(() => { + if (conversationsQuery.data && conversationsQuery.data.length > 0) { + const latestConversation = conversationsQuery.data.sort((a, b) => + b.mostRecentTimestamp - a.mostRecentTimestamp + )[0]; + setCurrentConversationId(latestConversation.id); + } else if (conversationsQuery.data && conversationsQuery.data.length === 0) { + setCurrentConversationId(null); + } + }, [conversationsQuery.data, sessionId, setCurrentConversationId]); + + const messagesQuery = useQuery({ + enabled: !!currentConversationId, + queryKey: ["messages", currentConversationId], + queryFn: async () => { + if (!currentConversationId) { + return []; + } + + const dbMessages = await dbCommands.listMessagesV2(currentConversationId); + + const uiMessages: UIMessage[] = dbMessages.map(msg => { + let parts = []; + let metadata = {}; + + try { + parts = JSON.parse(msg.parts); + } catch (error) { + console.error("Failed to parse message parts:", msg.id, error); + + parts = [{ type: "text", text: "" }]; + } + + if (msg.metadata) { + try { + metadata = JSON.parse(msg.metadata); + } catch (error) { + console.error("Failed to parse message metadata:", msg.id, error); + } + } + + return { + id: msg.id, + role: msg.role as "user" | "assistant" | "system", + content: parts, + parts: parts, + createdAt: new Date(msg.created_at), + metadata, + }; + }); + + return uiMessages; + }, + }); + + useEffect(() => { + if (messagesQuery.data && !isGenerating) { + setMessages(messagesQuery.data); + } + }, [messagesQuery.data, isGenerating, setMessages]); + + const sessionDataQuery = useQuery({ + enabled: !!sessionId, + queryKey: ["session", "chat-context", sessionId], + queryFn: async () => { + if (!sessionId) { + return null; + } + + const session = await dbCommands.getSession({ id: sessionId }); + if (!session) { + return null; + } + + return { + title: session.title || "", + rawContent: session.raw_memo_html || "", + enhancedContent: session.enhanced_memo_html, + preMeetingContent: session.pre_meeting_memo_html, + words: session.words || [], + }; + }, + }); + + const createConversation = async (): Promise => { + if (!sessionId || !userId) { + throw new Error("No session or user"); + } + + const conversation = await dbCommands.createConversation({ + id: crypto.randomUUID(), + session_id: sessionId, + user_id: userId, + name: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + setCurrentConversationId(conversation.id); + conversationsQuery.refetch(); + return conversation.id; + }; + + const getOrCreateConversationId = async (): Promise => { + if (currentConversationId) { + return currentConversationId; + } + return createConversation(); + }; + + return { + conversations: conversationsQuery.data || [], + conversationsLoading: conversationsQuery.isLoading, + messages: messagesQuery.data || [], + messagesLoading: messagesQuery.isLoading, + sessionData: sessionDataQuery.data, + sessionDataLoading: sessionDataQuery.isLoading, + createConversation, + getOrCreateConversationId, + refetchConversations: conversationsQuery.refetch, + refetchMessages: messagesQuery.refetch, + }; +} diff --git a/apps/desktop/src/components/right-panel/utils/chat-transport.ts b/apps/desktop/src/components/right-panel/utils/chat-transport.ts new file mode 100644 index 0000000000..a70e7616bc --- /dev/null +++ b/apps/desktop/src/components/right-panel/utils/chat-transport.ts @@ -0,0 +1,274 @@ +import { commands as connectorCommands } from "@hypr/plugin-connector"; +import { commands as mcpCommands } from "@hypr/plugin-mcp"; +import { fetch as tauriFetch } from "@hypr/utils"; +import type { UIMessage } from "@hypr/utils/ai"; +import { + type ChatRequestOptions, + type ChatTransport, + dynamicTool, + experimental_createMCPClient, + modelProvider, + smoothStream, + stepCountIs, + streamText, + type UIMessageChunk, +} from "@hypr/utils/ai"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { getLicenseKey } from "tauri-plugin-keygen-api"; +import { z } from "zod"; + +// Import the custom tools +import { prepareMessagesForAI } from "./chat-utils"; +import { buildVercelToolsFromMcp } from "./mcp-http-wrapper"; +import { createEditEnhancedNoteTool } from "./tools/edit_enhanced_note"; +import { createSearchSessionDateRangeTool } from "./tools/search_session_date_range"; +import { createSearchSessionTool } from "./tools/search_session_multi_keywords"; + +interface CustomChatTransportOptions { + sessionId: string | null; + userId: string | null; + sessionData?: any; + selectionData?: any; + sessions?: any; + getLicense?: { data?: { valid?: boolean } }; + mentionedContent?: Array<{ id: string; type: string; label: string }>; +} + +export class CustomChatTransport implements ChatTransport { + private options: CustomChatTransportOptions; + private allMcpClients: any[] = []; + private hyprMcpClient: Client | null = null; + + constructor(options: CustomChatTransportOptions) { + this.options = options; + } + + async initializeModel() { + const provider = await modelProvider(); + const model = provider.languageModel("defaultModel"); + + return model; + } + + private async loadMCPTools() { + let newMcpTools: Record = {}; + let hyprMcpTools: Record = {}; + + const llmConnection = await connectorCommands.getLlmConnection(); + const { type } = llmConnection; + const apiBase = llmConnection.connection?.api_base; + const customModel = await connectorCommands.getCustomLlmModel(); + + const modelId = type === "Custom" && customModel ? customModel : "gpt-4"; + + const shouldUseTools = modelId === "gpt-4.1" + || modelId === "openai/gpt-4.1" + || modelId === "anthropic/claude-sonnet-4" + || modelId === "openai/gpt-4o" + || modelId === "gpt-4o" + || apiBase?.includes("pro.hyprnote.com") + || modelId === "openai/gpt-5"; + + if (!shouldUseTools) { + return { newMcpTools, hyprMcpTools }; + } + + const mcpServers = await mcpCommands.getServers(); + const enabledServers = mcpServers.filter((server) => server.enabled); + + // load Hyprnote cloud MCP if applicable + if (apiBase?.includes("pro.hyprnote.com") && this.options.getLicense?.data?.valid) { + try { + const licenseKey = await getLicenseKey(); + const transport = new StreamableHTTPClientTransport( + new URL("https://pro.hyprnote.com/mcp"), + { + fetch: tauriFetch, + requestInit: { + headers: { + "x-hyprnote-license-key": licenseKey || "", + }, + }, + }, + ); + this.hyprMcpClient = new Client({ + name: "hyprmcp", + version: "0.1.0", + }); + await this.hyprMcpClient.connect(transport); + hyprMcpTools = await buildVercelToolsFromMcp(this.hyprMcpClient); + } catch (error) { + console.error("Error creating hyprmcp client:", error); + } + } + + for (const server of enabledServers) { + try { + const mcpClient = await experimental_createMCPClient({ + transport: { + type: "sse", + url: server.url, + ...(server.headerKey && server.headerValue && { + headers: { + [server.headerKey]: server.headerValue, + }, + }), + onerror: (error: any) => console.log("mcp client error:", error), + onclose: () => console.log("mcp client closed"), + }, + }); + this.allMcpClients.push(mcpClient); + + const tools = await mcpClient.tools(); + for (const [toolName, tool] of Object.entries(tools as Record)) { + newMcpTools[toolName] = dynamicTool({ + description: tool.description, + inputSchema: tool.inputSchema || z.any(), + execute: tool.execute, + }); + } + } catch (error) { + console.error("Error creating MCP client:", error); + } + } + + return { newMcpTools, hyprMcpTools }; + } + + private async getTools() { + const { newMcpTools, hyprMcpTools } = await this.loadMCPTools(); + + const llmConnection = await connectorCommands.getLlmConnection(); + const { type } = llmConnection; + const apiBase = llmConnection.connection?.api_base; + const customModel = await connectorCommands.getCustomLlmModel(); + + const modelId = type === "Custom" && customModel ? customModel : "gpt-4"; + + const shouldUseTools = modelId === "gpt-4.1" + || modelId === "openai/gpt-4.1" + || modelId === "anthropic/claude-sonnet-4" + || modelId === "openai/gpt-4o" + || modelId === "gpt-4o" + || apiBase?.includes("pro.hyprnote.com") + || modelId === "openai/gpt-5"; + + const searchTool = createSearchSessionTool(this.options.userId); + const searchSessionDateRangeTool = createSearchSessionDateRangeTool(this.options.userId); + const editEnhancedNoteTool = this.options.selectionData + ? createEditEnhancedNoteTool({ + sessionId: this.options.sessionId, + sessions: this.options.sessions || {}, + selectionData: this.options.selectionData, + }) + : null; + + const baseTools = { + ...(editEnhancedNoteTool && { edit_enhanced_note: editEnhancedNoteTool }), + search_sessions_date_range: searchSessionDateRangeTool, + search_sessions_multi_keywords: searchTool, + }; + + return { + ...(shouldUseTools && { ...hyprMcpTools, ...newMcpTools }), + ...(shouldUseTools && baseTools), + }; + } + + async sendMessages( + options: { + chatId: string; + messages: UIMessage[]; + abortSignal: AbortSignal | undefined; + } & { + trigger: "submit-message" | "regenerate-message"; + messageId: string | undefined; + } & ChatRequestOptions, + ): Promise> { + try { + const model = await this.initializeModel(); + + const lastMessage = options.messages[options.messages.length - 1]; + const messageMetadata = lastMessage?.metadata as any; + if (messageMetadata?.selectionData) { + this.options.selectionData = messageMetadata.selectionData; + } + + const tools = await this.getTools(); + + const preparedMessages = await prepareMessagesForAI(options.messages, { + sessionId: this.options.sessionId, + userId: this.options.userId, + sessionData: this.options.sessionData, + selectionData: this.options.selectionData, + mentionedContent: this.options.mentionedContent, + }); + + const result = streamText({ + model, + messages: preparedMessages, + abortSignal: options.abortSignal, + stopWhen: stepCountIs(10), + tools, + toolChoice: "auto", + experimental_transform: smoothStream({ + delayInMs: 70, + chunking: "word", + }), + onFinish: () => { + for (const client of this.allMcpClients) { + client.close(); + } + if (this.hyprMcpClient) { + this.hyprMcpClient.close(); + } + this.allMcpClients = []; + this.hyprMcpClient = null; + }, + }); + + return result.toUIMessageStream({ + onError: (error) => { + if (error == null) { + return "unknown_error"; + } + if (typeof error === "string") { + return error; + } + if (error instanceof Error) { + return error.message; + } + return JSON.stringify(error); + }, + }); + } catch (error) { + console.error("Transport error:", error); + throw error; + } + } + + async reconnectToStream( + _options: { + chatId: string; + } & ChatRequestOptions, + ): Promise | null> { + return null; + } + + // helper method to update options (for selection data, session data, etc.) + updateOptions(newOptions: Partial) { + this.options = { ...this.options, ...newOptions }; + } + + cleanup() { + for (const client of this.allMcpClients) { + client.close(); + } + if (this.hyprMcpClient) { + this.hyprMcpClient.close(); + } + this.allMcpClients = []; + this.hyprMcpClient = null; + } +} diff --git a/apps/desktop/src/components/right-panel/utils/chat-utils.ts b/apps/desktop/src/components/right-panel/utils/chat-utils.ts index 3fb3bb7877..f73937d781 100644 --- a/apps/desktop/src/components/right-panel/utils/chat-utils.ts +++ b/apps/desktop/src/components/right-panel/utils/chat-utils.ts @@ -2,7 +2,8 @@ import type { SelectionData } from "@/contexts/right-panel"; import { commands as connectorCommands } from "@hypr/plugin-connector"; import { commands as dbCommands } from "@hypr/plugin-db"; import { commands as templateCommands } from "@hypr/plugin-template"; -import { Message } from "../components/chat/types"; +import type { UIMessage } from "@hypr/utils/ai"; +import { convertToModelMessages } from "@hypr/utils/ai"; export const formatDate = (date: Date) => { const now = new Date(); @@ -33,27 +34,101 @@ export const focusInput = (chatInputRef: React.RefObject) = } }; -export const prepareMessageHistory = async ( - messages: Message[], - currentUserMessage?: string, - mentionedContent?: Array<{ id: string; type: string; label: string }>, - modelId?: string, - mcpToolsArray?: Array<{ name: string; description: string; inputSchema: string }>, - sessionData?: any, - sessionId?: string | null, - userId?: string | null, - apiBase?: string | null, - selectionData?: SelectionData, // Add selectionData parameter -) => { - const refetchResult = await sessionData?.refetch(); - let freshSessionData = refetchResult?.data; +/** + * Cleans UIMessages to remove tool parts with problematic states + * that are not compatible with model messages. + * This is a workaround for the Vercel AI SDK v5 limitation. + */ +export const cleanUIMessages = (messages: UIMessage[]): UIMessage[] => { + return messages.map(message => { + // Only process messages that have parts + if (!message.parts || !Array.isArray(message.parts)) { + return message; + } - const { type } = await connectorCommands.getLlmConnection(); + // Filter out tool parts with problematic states + const cleanedParts = message.parts.filter(part => { + // Check if this is a tool part (dynamic-tool or tool-*) + if (part.type === "dynamic-tool" || part.type?.startsWith("tool-")) { + const toolPart = part as any; + + // Filter out UI-specific states that cause conversion errors + // Keep only text parts and tool parts without problematic states + if ( + toolPart.state === "input-available" + || toolPart.state === "output-available" + || toolPart.state === "input-streaming" + || toolPart.state === "output-error" + ) { + return false; // Remove these tool parts + } + } - const participants = sessionId ? await dbCommands.sessionListParticipants(sessionId) : []; + // Keep all other parts (text, etc.) + return true; + }); - const calendarEvent = sessionId ? await dbCommands.sessionGetEvent(sessionId) : null; + return { + ...message, + parts: cleanedParts, + }; + }); +}; +/** + * Prepares messages for AI model with system prompt and context. + * Works with UIMessage types from Vercel AI SDK v5. + */ +export const prepareMessagesForAI = async ( + messages: UIMessage[], + options: { + sessionId: string | null; + userId: string | null; + sessionData?: any; + selectionData?: SelectionData; + mentionedContent?: Array<{ id: string; type: string; label: string }>; + }, +) => { + const { sessionId, userId, sessionData, selectionData, mentionedContent } = options; + + // sessionData is already the data object from the query, not the query itself + // It doesn't have a refetch method - it's just the plain data + let freshSessionData = sessionData; + + // If no session data and we have sessionId, fetch it directly + if (!freshSessionData && sessionId) { + try { + const session = await dbCommands.getSession({ id: sessionId }); + if (session) { + freshSessionData = { + title: session.title || "", + rawContent: session.raw_memo_html || "", + enhancedContent: session.enhanced_memo_html, + preMeetingContent: session.pre_meeting_memo_html, + words: session.words || [], + }; + } + } catch (error) { + console.error("Error fetching session data:", error); + } + } + + // Get connection info + const llmConnection = await connectorCommands.getLlmConnection(); + const { type } = llmConnection; + const apiBase = llmConnection.connection?.api_base; + const customModel = await connectorCommands.getCustomLlmModel(); + const modelId = type === "Custom" && customModel ? customModel : "gpt-4"; + + // Get participants and calendar event + const participants = sessionId + ? await dbCommands.sessionListParticipants(sessionId) + : []; + const calendarEvent = sessionId + ? await dbCommands.sessionGetEvent(sessionId) + : null; + + // Format current date/time const currentDateTime = new Date().toLocaleString("en-US", { year: "numeric", month: "long", @@ -63,12 +138,14 @@ export const prepareMessageHistory = async ( hour12: true, }); + // Format event info const eventInfo = calendarEvent - ? `${calendarEvent.name} (${calendarEvent.start_date} - ${calendarEvent.end_date})${ + ? `${calendarEvent.name} (${calendarEvent.start_date} - ${calendarEvent.end_date}${ calendarEvent.note ? ` - ${calendarEvent.note}` : "" - }` + })` : ""; + // Determine if tools are enabled const toolEnabled = !!( modelId === "gpt-4.1" || modelId === "openai/gpt-4.1" @@ -79,6 +156,17 @@ export const prepareMessageHistory = async ( || (apiBase && apiBase.includes("pro.hyprnote.com")) ); + // Get MCP tools list for system prompt + const mcpCommands = await import("@hypr/plugin-mcp").then(m => m.commands); + const mcpServers = await mcpCommands.getServers(); + const enabledServers = mcpServers.filter((server) => server.enabled); + const mcpToolsArray = enabledServers.map((server) => ({ + name: server.type, // Using type as name since that's what's available + description: "", + inputSchema: "{}", + })); + + // Generate system message using template const systemContent = await templateCommands.render("chat.system", { session: freshSessionData, words: JSON.stringify(freshSessionData?.words || []), @@ -94,130 +182,138 @@ export const prepareMessageHistory = async ( mcpTools: mcpToolsArray, }); - const conversationHistory: Array<{ - role: "system" | "user" | "assistant"; - content: string; - }> = [ - { role: "system" as const, content: systemContent }, - ]; - - messages.forEach(message => { - conversationHistory.push({ - role: message.isUser ? ("user" as const) : ("assistant" as const), - content: message.content, - }); - }); - - const processedMentions: Array<{ type: string; label: string; content: string }> = []; + // Clean UIMessages to remove problematic tool states before conversion + const cleanedMessages = cleanUIMessages(messages); - if (mentionedContent && mentionedContent.length > 0) { - for (const mention of mentionedContent) { - try { - if (mention.type === "note") { - const sessionData = await dbCommands.getSession({ id: mention.id }); + // Convert cleaned UIMessages to model messages + const modelMessages = convertToModelMessages(cleanedMessages); + const preparedMessages: any[] = []; - if (sessionData) { - let noteContent = ""; + // Always add system message first + preparedMessages.push({ + role: "system", + content: systemContent, + }); - if (sessionData.enhanced_memo_html && sessionData.enhanced_memo_html.trim() !== "") { - noteContent = sessionData.enhanced_memo_html; - } else if (sessionData.raw_memo_html && sessionData.raw_memo_html.trim() !== "") { - noteContent = sessionData.raw_memo_html; - } else { - continue; + // Process all messages, enhancing the last user message if needed + for (let i = 0; i < modelMessages.length; i++) { + const msg = modelMessages[i]; + + // Check if this is the last user message and we have context to add + const isLastUserMessage = i === modelMessages.length - 1 && msg.role === "user"; + + if (isLastUserMessage && (mentionedContent || selectionData)) { + // Process mentions + const processedMentions: Array<{ type: string; label: string; content: string }> = []; + + if (mentionedContent && mentionedContent.length > 0) { + for (const mention of mentionedContent) { + try { + if (mention.type === "note") { + const sessionData = await dbCommands.getSession({ id: mention.id }); + if (sessionData) { + let noteContent = ""; + if (sessionData.enhanced_memo_html && sessionData.enhanced_memo_html.trim() !== "") { + noteContent = sessionData.enhanced_memo_html; + } else if (sessionData.raw_memo_html && sessionData.raw_memo_html.trim() !== "") { + noteContent = sessionData.raw_memo_html; + } else { + continue; + } + processedMentions.push({ + type: "note", + label: mention.label, + content: noteContent, + }); + } } - processedMentions.push({ - type: "note", - label: mention.label, - content: noteContent, - }); - } - } - - if (mention.type === "human") { - const humanData = await dbCommands.getHuman(mention.id); - - let humanContent = ""; - humanContent += "Name: " + humanData?.full_name + "\n"; - humanContent += "Email: " + humanData?.email + "\n"; - humanContent += "Job Title: " + humanData?.job_title + "\n"; - humanContent += "LinkedIn: " + humanData?.linkedin_username + "\n"; - - if (humanData?.full_name) { - try { - const participantSessions = await dbCommands.listSessions({ - type: "search", - query: humanData.full_name, - user_id: userId || "", - limit: 5, - }); - - if (participantSessions.length > 0) { - humanContent += "\nNotes this person participated in:\n"; - - for (const session of participantSessions.slice(0, 2)) { - const participants = await dbCommands.sessionListParticipants(session.id); - const isParticipant = participants.some((p: any) => - p.full_name === humanData.full_name || p.email === humanData.email - ); - - if (isParticipant) { - let briefContent = ""; - if (session.enhanced_memo_html && session.enhanced_memo_html.trim() !== "") { - const div = document.createElement("div"); - div.innerHTML = session.enhanced_memo_html; - briefContent = (div.textContent || div.innerText || "").slice(0, 200) + "..."; - } else if (session.raw_memo_html && session.raw_memo_html.trim() !== "") { - const div = document.createElement("div"); - div.innerHTML = session.raw_memo_html; - briefContent = (div.textContent || div.innerText || "").slice(0, 200) + "..."; + if (mention.type === "human") { + const humanData = await dbCommands.getHuman(mention.id); + if (humanData) { + let humanContent = ""; + humanContent += "Name: " + humanData?.full_name + "\n"; + humanContent += "Email: " + humanData?.email + "\n"; + humanContent += "Job Title: " + humanData?.job_title + "\n"; + humanContent += "LinkedIn: " + humanData?.linkedin_username + "\n"; + + // Add recent sessions for this person + if (humanData?.full_name) { + try { + const participantSessions = await dbCommands.listSessions({ + type: "search", + query: humanData.full_name, + user_id: userId || "", + limit: 5, + }); + + if (participantSessions.length > 0) { + humanContent += "\nNotes this person participated in:\n"; + for (const session of participantSessions.slice(0, 2)) { + const participants = await dbCommands.sessionListParticipants(session.id); + const isParticipant = participants.some((p: any) => + p.full_name === humanData.full_name || p.email === humanData.email + ); + + if (isParticipant) { + let briefContent = ""; + if (session.enhanced_memo_html && session.enhanced_memo_html.trim() !== "") { + // Strip HTML tags for brief content + briefContent = session.enhanced_memo_html.replace(/<[^>]*>/g, "").slice(0, 200) + "..."; + } else if (session.raw_memo_html && session.raw_memo_html.trim() !== "") { + briefContent = session.raw_memo_html.replace(/<[^>]*>/g, "").slice(0, 200) + "..."; + } + humanContent += `- "${session.title || "Untitled"}": ${briefContent}\n`; + } + } } - - humanContent += `- "${session.title || "Untitled"}": ${briefContent}\n`; + } catch (error) { + console.error(`Error fetching notes for person "${humanData.full_name}":`, error); } } + + processedMentions.push({ + type: "human", + label: mention.label, + content: humanContent, + }); } - } catch (error) { - console.error(`Error fetching notes for person "${humanData.full_name}":`, error); } - } - - if (humanData) { - processedMentions.push({ - type: "human", - label: mention.label, - content: humanContent, - }); + } catch (error) { + console.error(`Error fetching content for "${mention.label}":`, error); } } - } catch (error) { - console.error(`Error fetching content for "${mention.label}":`, error); } - } - } - - // Use the user template to format the user message - if (currentUserMessage) { - const userContent = await templateCommands.render("chat.user", { - message: currentUserMessage, - mentionedContent: processedMentions, - selectionData: selectionData - ? { - text: selectionData.text, - startOffset: selectionData.startOffset, - endOffset: selectionData.endOffset, - sessionId: selectionData.sessionId, - timestamp: selectionData.timestamp, - } - : undefined, // Convert to plain object for JsonValue compatibility - }); - conversationHistory.push({ - role: "user" as const, - content: userContent, - }); + // Get the original user message content + const originalContent = typeof msg.content === "string" + ? msg.content + : msg.content.map((part: any) => part.type === "text" ? part.text : "").join(""); + + // Use the user template to format the enhanced message + const enhancedContent = await templateCommands.render("chat.user", { + message: originalContent, + mentionedContent: processedMentions, + selectionData: selectionData + ? { + text: selectionData.text, + startOffset: selectionData.startOffset, + endOffset: selectionData.endOffset, + sessionId: selectionData.sessionId, + timestamp: selectionData.timestamp, + } + : undefined, + }); + + preparedMessages.push({ + role: "user", + content: enhancedContent, + }); + } else { + // For all other messages, just add them as-is + preparedMessages.push(msg); + } } - return conversationHistory; + return preparedMessages; }; diff --git a/apps/desktop/src/components/right-panel/utils/markdown-parser.ts b/apps/desktop/src/components/right-panel/utils/markdown-parser.ts index 6ce1908df7..b83cf263eb 100644 --- a/apps/desktop/src/components/right-panel/utils/markdown-parser.ts +++ b/apps/desktop/src/components/right-panel/utils/markdown-parser.ts @@ -1,7 +1,12 @@ -import { MessagePart } from "../components/chat/types"; +// Simple type for parsed markdown parts +export interface ParsedPart { + type: "text" | "markdown"; + content: string; + isComplete?: boolean; +} -export const parseMarkdownBlocks = (text: string): MessagePart[] => { - const parts: MessagePart[] = []; +export const parseMarkdownBlocks = (text: string): ParsedPart[] => { + const parts: ParsedPart[] = []; let currentIndex = 0; let inMarkdownBlock = false; let markdownStart = -1; diff --git a/apps/desktop/src/components/right-panel/utils/tools/edit_enhanced_note.ts b/apps/desktop/src/components/right-panel/utils/tools/edit_enhanced_note.ts index 282cde25d3..7196c30c00 100644 --- a/apps/desktop/src/components/right-panel/utils/tools/edit_enhanced_note.ts +++ b/apps/desktop/src/components/right-panel/utils/tools/edit_enhanced_note.ts @@ -29,9 +29,13 @@ export const createEditEnhancedNoteTool = ({ return { success: false, error: "No session ID available" }; } - const sessionStore = sessions[sessionId]; - if (!sessionStore) { - return { success: false, error: "Session not found" }; + // Skip session store check if sessions object is empty + // We rely on having a valid sessionId and editor ref instead + if (sessions && Object.keys(sessions).length > 0) { + const sessionStore = sessions[sessionId]; + if (!sessionStore) { + console.warn("Session store not found in sessions object, but continuing with editor ref"); + } } try { diff --git a/apps/desktop/src/components/right-panel/views/chat-view.tsx b/apps/desktop/src/components/right-panel/views/chat-view.tsx index 941e8132fa..8ce8cc231f 100644 --- a/apps/desktop/src/components/right-panel/views/chat-view.tsx +++ b/apps/desktop/src/components/right-panel/views/chat-view.tsx @@ -1,9 +1,12 @@ -import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; +import { showProGateModal } from "@/components/pro-gate-modal/service"; import { useHypr, useRightPanel } from "@/contexts"; -import { commands as connectorCommands } from "@hypr/plugin-connector"; +import { useLicense } from "@/hooks/use-license"; +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { commands as miscCommands } from "@hypr/plugin-misc"; +import { useSessions } from "@hypr/utils/contexts"; import { ChatHistoryView, ChatInput, @@ -14,73 +17,190 @@ import { } from "../components/chat"; import { useActiveEntity } from "../hooks/useActiveEntity"; -import { useChatLogic } from "../hooks/useChatLogic"; -import { useChatQueries } from "../hooks/useChatQueries"; -import type { Message } from "../types/chat-types"; +import { useChat2 } from "../hooks/useChat2"; +import { useChatQueries2 } from "../hooks/useChatQueries2"; import { focusInput, formatDate } from "../utils/chat-utils"; export function ChatView() { const navigate = useNavigate(); - const { isExpanded, chatInputRef } = useRightPanel(); + const { isExpanded, chatInputRef, pendingSelection } = useRightPanel(); const { userId } = useHypr(); + const { getLicense } = useLicense(); - const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); const [showHistory, setShowHistory] = useState(false); const [searchValue, setSearchValue] = useState(""); - const [hasChatStarted, setHasChatStarted] = useState(false); - const [currentChatGroupId, setCurrentChatGroupId] = useState(null); + const [currentConversationId, setCurrentConversationId] = useState(null); const [chatHistory, _setChatHistory] = useState([]); - const prevIsGenerating = useRef(false); - const { activeEntity, sessionId } = useActiveEntity({ - setMessages, + setMessages: () => {}, setInputValue, setShowHistory, - setHasChatStarted, + setHasChatStarted: () => {}, }); - const llmConnectionQuery = useQuery({ - queryKey: ["llm-connection"], - queryFn: () => connectorCommands.getLlmConnection(), - refetchOnWindowFocus: true, - }); + const sessions = useSessions((s) => s.sessions); - const { chatGroupsQuery, sessionData, getChatGroupId } = useChatQueries({ + const { + conversations, + sessionData, + createConversation, + getOrCreateConversationId, + } = useChatQueries2({ sessionId, userId, - currentChatGroupId, - setCurrentChatGroupId, - setMessages, - setHasChatStarted, - prevIsGenerating, + currentConversationId, + setCurrentConversationId, + setMessages: () => {}, + isGenerating: false, }); const { + messages, + stop, + setMessages, isGenerating, - isStreamingText, - handleSubmit, - handleQuickAction, - handleApplyMarkdown, - handleKeyDown, - handleStop, - } = useChatLogic({ + sendMessage, + status, + } = useChat2({ sessionId, userId, - activeEntity, - messages, - inputValue, - hasChatStarted, - setMessages, - setInputValue, - setHasChatStarted, - getChatGroupId, - sessionData, - chatInputRef, - llmConnectionQuery, + conversationId: currentConversationId, + sessionData: sessionData, + selectionData: pendingSelection, + onError: (err: Error) => { + console.error("Chat error:", err); + }, }); + useEffect(() => { + const loadMessages = async () => { + if (currentConversationId) { + try { + const { commands } = await import("@hypr/plugin-db"); + const dbMessages = await commands.listMessagesV2(currentConversationId); + + const uiMessages = dbMessages.map(msg => ({ + id: msg.id, + role: msg.role as "user" | "assistant" | "system", + parts: JSON.parse(msg.parts), + metadata: msg.metadata ? JSON.parse(msg.metadata) : {}, + })); + + setMessages(uiMessages); + } catch (error) { + console.error("Failed to load messages:", error); + } + } else { + setMessages([]); + } + }; + + loadMessages(); + }, [currentConversationId, setMessages]); + + const handleSubmit = async ( + mentionedContent?: Array<{ id: string; type: string; label: string }>, + selectionData?: any, + htmlContent?: string, + ) => { + if (!inputValue.trim()) { + return; + } + + const userMessageCount = messages.filter((m: any) => m.role === "user").length; + if (userMessageCount >= 4 && !getLicense.data?.valid) { + await analyticsCommands.event({ + event: "pro_license_required_chat", + distinct_id: userId, + }); + await showProGateModal("chat"); + return; + } + + analyticsCommands.event({ + event: "chat_message_sent", + distinct_id: userId, + }); + + let convId = currentConversationId; + if (!convId) { + convId = await getOrCreateConversationId(); + if (!convId) { + console.error("Failed to create conversation"); + return; + } + setCurrentConversationId(convId); + } + + sendMessage(inputValue, { + mentionedContent, + selectionData, + htmlContent, + conversationId: convId, + }); + + setInputValue(""); + }; + + const handleStop = () => { + stop(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleQuickAction = async (action: string) => { + const convId = await createConversation(); + if (!convId) { + console.error("Failed to create conversation"); + return; + } + + setCurrentConversationId(convId); + + sendMessage(action, { + conversationId: convId, + }); + }; + + const handleApplyMarkdown = async (markdownContent: string) => { + if (!sessionId) { + console.error("No session ID available"); + return; + } + + const sessionStore = sessions[sessionId]; + if (!sessionStore) { + console.error("Session not found in store"); + return; + } + + try { + const html = await miscCommands.opinionatedMdToHtml(markdownContent); + + const { showRaw, updateRawNote, updateEnhancedNote } = sessionStore.getState(); + + if (showRaw) { + updateRawNote(html); + } else { + updateEnhancedNote(html); + } + } catch (error) { + console.error("Failed to apply markdown content:", error); + } + }; + + const isSubmitted = status === "submitted"; + const isStreaming = status === "streaming"; + const isReady = status === "ready"; + const isError = status === "error"; + const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); }; @@ -89,19 +209,29 @@ export function ChatView() { focusInput(chatInputRef); }; - const handleNewChat = async () => { + const handleNewChat = () => { + if (!messages || messages.length === 0) { + return; + } + if (!sessionId || !userId) { return; } - setCurrentChatGroupId(null); - setMessages([]); - setHasChatStarted(false); + if (isGenerating) { + return; + } + + setCurrentConversationId(null); setInputValue(""); + setMessages([]); }; const handleSelectChatGroup = async (groupId: string) => { - setCurrentChatGroupId(groupId); + if (isGenerating) { + return; + } + setCurrentConversationId(groupId); }; const handleViewHistory = () => { @@ -112,7 +242,7 @@ export function ChatView() { setSearchValue(e.target.value); }; - const handleSelectChat = (chatId: string) => { + const handleSelectChat = (_chatId: string) => { setShowHistory(false); }; @@ -155,7 +285,7 @@ export function ChatView() { @@ -169,11 +299,13 @@ export function ChatView() { : ( )} diff --git a/crates/db-user/src/chat_conversations_migration.sql b/crates/db-user/src/chat_conversations_migration.sql new file mode 100644 index 0000000000..03214e21dc --- /dev/null +++ b/crates/db-user/src/chat_conversations_migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS chat_conversations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); diff --git a/crates/db-user/src/chat_conversations_ops.rs b/crates/db-user/src/chat_conversations_ops.rs new file mode 100644 index 0000000000..83bdb41eb3 --- /dev/null +++ b/crates/db-user/src/chat_conversations_ops.rs @@ -0,0 +1,76 @@ +use super::{ChatConversation, UserDatabase}; + +impl UserDatabase { + pub async fn create_conversation( + &self, + conversation: ChatConversation, + ) -> Result { + let conn = self.conn()?; + + let mut rows = conn + .query( + "INSERT INTO chat_conversations ( + id, session_id, user_id, name, created_at, +updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + RETURNING *", + vec![ + conversation.id, + conversation.session_id, + conversation.user_id, + conversation.name.unwrap_or_default(), + conversation.created_at.to_rfc3339(), + conversation.updated_at.to_rfc3339(), + ], + ) + .await?; + + let row = rows.next().await?.unwrap(); + let conversation: ChatConversation = libsql::de::from_row(&row)?; + Ok(conversation) + } + + pub async fn list_conversations( + &self, + session_id: impl Into, + ) -> Result, crate::Error> { + let conn = self.conn()?; + + let mut rows = conn + .query( + "SELECT * FROM chat_conversations + WHERE session_id = ? + ORDER BY updated_at DESC", + vec![session_id.into()], + ) + .await?; + + let mut conversations = Vec::new(); + while let Some(row) = rows.next().await? { + let conversation: ChatConversation = libsql::de::from_row(&row)?; + conversations.push(conversation); + } + Ok(conversations) + } + + pub async fn get_conversation( + &self, + id: impl Into, + ) -> Result, crate::Error> { + let conn = self.conn()?; + + let mut rows = conn + .query( + "SELECT * FROM chat_conversations WHERE id = ?", + vec![id.into()], + ) + .await?; + + if let Some(row) = rows.next().await? { + let conversation: ChatConversation = libsql::de::from_row(&row)?; + Ok(Some(conversation)) + } else { + Ok(None) + } + } +} diff --git a/crates/db-user/src/chat_conversations_types.rs b/crates/db-user/src/chat_conversations_types.rs new file mode 100644 index 0000000000..0724c66f1f --- /dev/null +++ b/crates/db-user/src/chat_conversations_types.rs @@ -0,0 +1,12 @@ +use crate::user_common_derives; + +user_common_derives! { + pub struct ChatConversation { + pub id: String, + pub session_id: String, + pub user_id: String, + pub name: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + } +} diff --git a/crates/db-user/src/chat_messages_v2_migration.sql b/crates/db-user/src/chat_messages_v2_migration.sql new file mode 100644 index 0000000000..1d93bdfb67 --- /dev/null +++ b/crates/db-user/src/chat_messages_v2_migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS chat_messages_v2 ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT CHECK(role IN ('system', 'user', 'assistant')) NOT NULL, + parts TEXT NOT NULL, + -- JSON string of message parts array + metadata TEXT, + -- JSON string for mentions, selections, etc. + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE +); diff --git a/crates/db-user/src/chat_messages_v2_ops.rs b/crates/db-user/src/chat_messages_v2_ops.rs new file mode 100644 index 0000000000..62cea259bc --- /dev/null +++ b/crates/db-user/src/chat_messages_v2_ops.rs @@ -0,0 +1,74 @@ +use super::{ChatMessageV2, UserDatabase}; + +impl UserDatabase { + pub async fn create_message_v2( + &self, + message: ChatMessageV2, + ) -> Result { + let conn = self.conn()?; + + let mut rows = conn + .query( + "INSERT INTO chat_messages_v2 ( + id, conversation_id, role, parts, metadata, +created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING *", + vec![ + message.id, + message.conversation_id, + message.role.to_string(), + message.parts, + message.metadata.unwrap_or_default(), + message.created_at.to_rfc3339(), + message.updated_at.to_rfc3339(), + ], + ) + .await?; + + let row = rows.next().await?.unwrap(); + let message: ChatMessageV2 = libsql::de::from_row(&row)?; + Ok(message) + } + + pub async fn list_messages_v2( + &self, + conversation_id: impl Into, + ) -> Result, crate::Error> { + let conn = self.conn()?; + + let mut rows = conn + .query( + "SELECT * FROM chat_messages_v2 + WHERE conversation_id = ? + ORDER BY created_at ASC", + vec![conversation_id.into()], + ) + .await?; + + let mut messages = Vec::new(); + while let Some(row) = rows.next().await? { + let message: ChatMessageV2 = libsql::de::from_row(&row)?; + messages.push(message); + } + Ok(messages) + } + + pub async fn update_message_v2_parts( + &self, + id: impl Into, + parts: impl Into, + ) -> Result<(), crate::Error> { + let conn = self.conn()?; + + conn.execute( + "UPDATE chat_messages_v2 + SET parts = ?, updated_at = ? + WHERE id = ?", + vec![parts.into(), chrono::Utc::now().to_rfc3339(), id.into()], + ) + .await?; + + Ok(()) + } +} diff --git a/crates/db-user/src/chat_messages_v2_types.rs b/crates/db-user/src/chat_messages_v2_types.rs new file mode 100644 index 0000000000..f8ef073c7f --- /dev/null +++ b/crates/db-user/src/chat_messages_v2_types.rs @@ -0,0 +1,28 @@ +use crate::user_common_derives; + +user_common_derives! { + #[derive(strum::EnumString, strum::Display)] + pub enum ChatMessageV2Role { + #[serde(rename = "system")] + #[strum(serialize = "system")] + System, + #[serde(rename = "user")] + #[strum(serialize = "user")] + User, + #[serde(rename = "assistant")] + #[strum(serialize = "assistant")] + Assistant, + } +} + +user_common_derives! { + pub struct ChatMessageV2 { + pub id: String, + pub conversation_id: String, + pub role: ChatMessageV2Role, + pub parts: String, // JSON string + pub metadata: Option, // JSON string + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + } +} diff --git a/crates/db-user/src/lib.rs b/crates/db-user/src/lib.rs index 402b9c1dc3..a9c11b01e8 100644 --- a/crates/db-user/src/lib.rs +++ b/crates/db-user/src/lib.rs @@ -1,9 +1,13 @@ mod calendars_ops; mod calendars_types; +mod chat_conversations_ops; +mod chat_conversations_types; mod chat_groups_ops; mod chat_groups_types; mod chat_messages_ops; mod chat_messages_types; +mod chat_messages_v2_ops; +mod chat_messages_v2_types; mod config_ops; mod config_types; mod events_ops; @@ -26,6 +30,10 @@ pub use calendars_ops::*; #[allow(unused)] pub use calendars_types::*; #[allow(unused)] +pub use chat_conversations_ops::*; +#[allow(unused)] +pub use chat_conversations_types::*; +#[allow(unused)] pub use chat_groups_ops::*; #[allow(unused)] pub use chat_groups_types::*; @@ -34,6 +42,10 @@ pub use chat_messages_ops::*; #[allow(unused)] pub use chat_messages_types::*; #[allow(unused)] +pub use chat_messages_v2_ops::*; +#[allow(unused)] +pub use chat_messages_v2_types::*; +#[allow(unused)] pub use config_ops::*; #[allow(unused)] pub use config_types::*; @@ -130,7 +142,7 @@ impl std::ops::Deref for UserDatabase { } // Append only. Do not reorder. -const MIGRATIONS: [&str; 25] = [ +const MIGRATIONS: [&str; 27] = [ include_str!("./calendars_migration.sql"), include_str!("./configs_migration.sql"), include_str!("./events_migration.sql"), @@ -156,6 +168,8 @@ const MIGRATIONS: [&str; 25] = [ include_str!("./chat_messages_migration_1.sql"), include_str!("./chat_messages_migration_2.sql"), include_str!("./templates_migration_1.sql"), + include_str!("./chat_conversations_migration.sql"), + include_str!("./chat_messages_v2_migration.sql"), ]; pub async fn migrate(db: &UserDatabase) -> Result<(), crate::Error> { diff --git a/crates/db-user/src/sessions_ops.rs b/crates/db-user/src/sessions_ops.rs index 33e748b139..36d4ad46fd 100644 --- a/crates/db-user/src/sessions_ops.rs +++ b/crates/db-user/src/sessions_ops.rs @@ -110,28 +110,6 @@ impl UserDatabase { let session_id = id.into(); let conn = self.conn()?; - let mut rows = conn - .query( - "SELECT id FROM chat_groups WHERE session_id = ?", - vec![session_id.clone()], - ) - .await?; - - while let Some(row) = rows.next().await? { - let group_id: String = row.get(0)?; - conn.execute( - "DELETE FROM chat_messages WHERE group_id = ?", - vec![group_id], - ) - .await?; - } - - conn.execute( - "DELETE FROM chat_groups WHERE session_id = ?", - vec![session_id.clone()], - ) - .await?; - conn.execute("DELETE FROM sessions WHERE id = ?", vec![session_id]) .await?; diff --git a/package.json b/package.json index 04af4f62f2..6b24e8360b 100644 --- a/package.json +++ b/package.json @@ -2,5 +2,8 @@ "packageManager": "pnpm@10.11.1", "devDependencies": { "turbo": "^2.5.6" + }, + "dependencies": { + "@ai-sdk/react": "^2.0.30" } } diff --git a/packages/utils/src/ai.ts b/packages/utils/src/ai.ts index 7903d84638..67930aab3a 100644 --- a/packages/utils/src/ai.ts +++ b/packages/utils/src/ai.ts @@ -5,17 +5,25 @@ import { getLicenseKey } from "tauri-plugin-keygen-api"; import { commands as connectorCommands } from "@hypr/plugin-connector"; import { fetch as customFetch } from "@hypr/utils"; +export { useChat } from "@ai-sdk/react"; + export { + type ChatRequestOptions, + type ChatTransport, + convertToModelMessages, dynamicTool, experimental_createMCPClient, generateObject, generateText, jsonSchema, + type LanguageModel, type Provider, smoothStream, stepCountIs, streamText, tool, + type UIMessage, + type UIMessageChunk, } from "ai"; export const localProviderName = "hypr-llm-local"; diff --git a/plugins/db/build.rs b/plugins/db/build.rs index ec052cd8aa..1f0c105c15 100644 --- a/plugins/db/build.rs +++ b/plugins/db/build.rs @@ -47,6 +47,11 @@ const COMMANDS: &[&str] = &[ "create_chat_group", "upsert_chat_message", "delete_chat_messages", + "create_conversation", + "list_conversations", + "create_message_v2", + "list_messages_v2", + "update_message_v2_parts", // tag "upsert_tag", "delete_tag", diff --git a/plugins/db/js/bindings.gen.ts b/plugins/db/js/bindings.gen.ts index 2edaeb223f..443f458e8e 100644 --- a/plugins/db/js/bindings.gen.ts +++ b/plugins/db/js/bindings.gen.ts @@ -147,6 +147,21 @@ async upsertTag(tag: Tag) : Promise { }, async deleteTag(tagId: string) : Promise { return await TAURI_INVOKE("plugin:db|delete_tag", { tagId }); +}, +async createConversation(conversation: ChatConversation) : Promise { + return await TAURI_INVOKE("plugin:db|create_conversation", { conversation }); +}, +async listConversations(sessionId: string) : Promise { + return await TAURI_INVOKE("plugin:db|list_conversations", { sessionId }); +}, +async createMessageV2(message: ChatMessageV2) : Promise { + return await TAURI_INVOKE("plugin:db|create_message_v2", { message }); +}, +async listMessagesV2(conversationId: string) : Promise { + return await TAURI_INVOKE("plugin:db|list_messages_v2", { conversationId }); +}, +async updateMessageV2Parts(id: string, parts: string) : Promise { + return await TAURI_INVOKE("plugin:db|update_message_v2_parts", { id, parts }); } } @@ -161,10 +176,13 @@ async deleteTag(tagId: string) : Promise { /** user-defined types **/ export type Calendar = { id: string; tracking_id: string; user_id: string; platform: Platform; name: string; selected: boolean; source: string | null } +export type ChatConversation = { id: string; session_id: string; user_id: string; name: string | null; created_at: string; updated_at: string } export type ChatGroup = { id: string; user_id: string; name: string | null; created_at: string; session_id: string } export type ChatMessage = { id: string; group_id: string; created_at: string; role: ChatMessageRole; content: string; type: ChatMessageType; tool_details: string | null } export type ChatMessageRole = "User" | "Assistant" export type ChatMessageType = "text-delta" | "tool-start" | "tool-result" | "tool-error" +export type ChatMessageV2 = { id: string; conversation_id: string; role: ChatMessageV2Role; parts: string; metadata: string | null; created_at: string; updated_at: string } +export type ChatMessageV2Role = "system" | "user" | "assistant" export type Config = { id: string; user_id: string; general: ConfigGeneral; notification: ConfigNotification; ai: ConfigAI } export type ConfigAI = { api_base: string | null; api_key: string | null; ai_specificity: number | null; redemption_time_ms: number | null } export type ConfigGeneral = { autostart: boolean; display_language: string; spoken_languages?: string[]; jargons?: string[]; telemetry_consent: boolean; save_recordings: boolean | null; selected_template_id: string | null; summary_language?: string } diff --git a/plugins/db/permissions/autogenerated/commands/create_conversation.toml b/plugins/db/permissions/autogenerated/commands/create_conversation.toml new file mode 100644 index 0000000000..72050e8b01 --- /dev/null +++ b/plugins/db/permissions/autogenerated/commands/create_conversation.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create-conversation" +description = "Enables the create_conversation command without any pre-configured scope." +commands.allow = ["create_conversation"] + +[[permission]] +identifier = "deny-create-conversation" +description = "Denies the create_conversation command without any pre-configured scope." +commands.deny = ["create_conversation"] diff --git a/plugins/db/permissions/autogenerated/commands/create_message_v2.toml b/plugins/db/permissions/autogenerated/commands/create_message_v2.toml new file mode 100644 index 0000000000..984569b693 --- /dev/null +++ b/plugins/db/permissions/autogenerated/commands/create_message_v2.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create-message-v2" +description = "Enables the create_message_v2 command without any pre-configured scope." +commands.allow = ["create_message_v2"] + +[[permission]] +identifier = "deny-create-message-v2" +description = "Denies the create_message_v2 command without any pre-configured scope." +commands.deny = ["create_message_v2"] diff --git a/plugins/db/permissions/autogenerated/commands/list_conversations.toml b/plugins/db/permissions/autogenerated/commands/list_conversations.toml new file mode 100644 index 0000000000..b81f80dde3 --- /dev/null +++ b/plugins/db/permissions/autogenerated/commands/list_conversations.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-list-conversations" +description = "Enables the list_conversations command without any pre-configured scope." +commands.allow = ["list_conversations"] + +[[permission]] +identifier = "deny-list-conversations" +description = "Denies the list_conversations command without any pre-configured scope." +commands.deny = ["list_conversations"] diff --git a/plugins/db/permissions/autogenerated/commands/list_messages_v2.toml b/plugins/db/permissions/autogenerated/commands/list_messages_v2.toml new file mode 100644 index 0000000000..4e6ee3cbbb --- /dev/null +++ b/plugins/db/permissions/autogenerated/commands/list_messages_v2.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-list-messages-v2" +description = "Enables the list_messages_v2 command without any pre-configured scope." +commands.allow = ["list_messages_v2"] + +[[permission]] +identifier = "deny-list-messages-v2" +description = "Denies the list_messages_v2 command without any pre-configured scope." +commands.deny = ["list_messages_v2"] diff --git a/plugins/db/permissions/autogenerated/commands/update_message_v2_parts.toml b/plugins/db/permissions/autogenerated/commands/update_message_v2_parts.toml new file mode 100644 index 0000000000..4515d85b9d --- /dev/null +++ b/plugins/db/permissions/autogenerated/commands/update_message_v2_parts.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-update-message-v2-parts" +description = "Enables the update_message_v2_parts command without any pre-configured scope." +commands.allow = ["update_message_v2_parts"] + +[[permission]] +identifier = "deny-update-message-v2-parts" +description = "Denies the update_message_v2_parts command without any pre-configured scope." +commands.deny = ["update_message_v2_parts"] diff --git a/plugins/db/permissions/autogenerated/reference.md b/plugins/db/permissions/autogenerated/reference.md index aa46cf001c..ba9c91077b 100644 --- a/plugins/db/permissions/autogenerated/reference.md +++ b/plugins/db/permissions/autogenerated/reference.md @@ -44,6 +44,11 @@ Default permissions for the plugin - `allow-create-chat-group` - `allow-upsert-chat-message` - `allow-delete-chat-messages` +- `allow-list-conversations` +- `allow-create-message-v2` +- `allow-create-conversation` +- `allow-list-messages-v2` +- `allow-update-message-v2-parts` - `allow-upsert-tag` - `allow-delete-tag` - `allow-list-all-tags` @@ -116,6 +121,58 @@ Denies the create_chat_group command without any pre-configured scope. +`db:allow-create-conversation` + + + + +Enables the create_conversation command without any pre-configured scope. + + + + + + + +`db:deny-create-conversation` + + + + +Denies the create_conversation command without any pre-configured scope. + + + + + + + +`db:allow-create-message-v2` + + + + +Enables the create_message_v2 command without any pre-configured scope. + + + + + + + +`db:deny-create-message-v2` + + + + +Denies the create_message_v2 command without any pre-configured scope. + + + + + + + `db:allow-delete-chat-messages` @@ -610,6 +667,32 @@ Denies the list_chat_messages command without any pre-configured scope. +`db:allow-list-conversations` + + + + +Enables the list_conversations command without any pre-configured scope. + + + + + + + +`db:deny-list-conversations` + + + + +Denies the list_conversations command without any pre-configured scope. + + + + + + + `db:allow-list-events` @@ -662,6 +745,32 @@ Denies the list_humans command without any pre-configured scope. +`db:allow-list-messages-v2` + + + + +Enables the list_messages_v2 command without any pre-configured scope. + + + + + + + +`db:deny-list-messages-v2` + + + + +Denies the list_messages_v2 command without any pre-configured scope. + + + + + + + `db:allow-list-organization-members` @@ -1078,6 +1187,32 @@ Denies the unassign_tag_from_session command without any pre-configured scope. +`db:allow-update-message-v2-parts` + + + + +Enables the update_message_v2_parts command without any pre-configured scope. + + + + + + + +`db:deny-update-message-v2-parts` + + + + +Denies the update_message_v2_parts command without any pre-configured scope. + + + + + + + `db:allow-upsert-calendar` diff --git a/plugins/db/permissions/default.toml b/plugins/db/permissions/default.toml index 8a6cf53d37..8f90a89631 100644 --- a/plugins/db/permissions/default.toml +++ b/plugins/db/permissions/default.toml @@ -48,6 +48,11 @@ permissions = [ "allow-create-chat-group", "allow-upsert-chat-message", "allow-delete-chat-messages", + "allow-list-conversations", + "allow-create-message-v2", + "allow-create-conversation", + "allow-list-messages-v2", + "allow-update-message-v2-parts", # tag "allow-upsert-tag", "allow-delete-tag", diff --git a/plugins/db/permissions/schemas/schema.json b/plugins/db/permissions/schemas/schema.json index dea48f27e8..ddf2dad5e1 100644 --- a/plugins/db/permissions/schemas/schema.json +++ b/plugins/db/permissions/schemas/schema.json @@ -318,6 +318,30 @@ "const": "deny-create-chat-group", "markdownDescription": "Denies the create_chat_group command without any pre-configured scope." }, + { + "description": "Enables the create_conversation command without any pre-configured scope.", + "type": "string", + "const": "allow-create-conversation", + "markdownDescription": "Enables the create_conversation command without any pre-configured scope." + }, + { + "description": "Denies the create_conversation command without any pre-configured scope.", + "type": "string", + "const": "deny-create-conversation", + "markdownDescription": "Denies the create_conversation command without any pre-configured scope." + }, + { + "description": "Enables the create_message_v2 command without any pre-configured scope.", + "type": "string", + "const": "allow-create-message-v2", + "markdownDescription": "Enables the create_message_v2 command without any pre-configured scope." + }, + { + "description": "Denies the create_message_v2 command without any pre-configured scope.", + "type": "string", + "const": "deny-create-message-v2", + "markdownDescription": "Denies the create_message_v2 command without any pre-configured scope." + }, { "description": "Enables the delete_chat_messages command without any pre-configured scope.", "type": "string", @@ -546,6 +570,18 @@ "const": "deny-list-chat-messages", "markdownDescription": "Denies the list_chat_messages command without any pre-configured scope." }, + { + "description": "Enables the list_conversations command without any pre-configured scope.", + "type": "string", + "const": "allow-list-conversations", + "markdownDescription": "Enables the list_conversations command without any pre-configured scope." + }, + { + "description": "Denies the list_conversations command without any pre-configured scope.", + "type": "string", + "const": "deny-list-conversations", + "markdownDescription": "Denies the list_conversations command without any pre-configured scope." + }, { "description": "Enables the list_events command without any pre-configured scope.", "type": "string", @@ -570,6 +606,18 @@ "const": "deny-list-humans", "markdownDescription": "Denies the list_humans command without any pre-configured scope." }, + { + "description": "Enables the list_messages_v2 command without any pre-configured scope.", + "type": "string", + "const": "allow-list-messages-v2", + "markdownDescription": "Enables the list_messages_v2 command without any pre-configured scope." + }, + { + "description": "Denies the list_messages_v2 command without any pre-configured scope.", + "type": "string", + "const": "deny-list-messages-v2", + "markdownDescription": "Denies the list_messages_v2 command without any pre-configured scope." + }, { "description": "Enables the list_organization_members command without any pre-configured scope.", "type": "string", @@ -762,6 +810,18 @@ "const": "deny-unassign-tag-from-session", "markdownDescription": "Denies the unassign_tag_from_session command without any pre-configured scope." }, + { + "description": "Enables the update_message_v2_parts command without any pre-configured scope.", + "type": "string", + "const": "allow-update-message-v2-parts", + "markdownDescription": "Enables the update_message_v2_parts command without any pre-configured scope." + }, + { + "description": "Denies the update_message_v2_parts command without any pre-configured scope.", + "type": "string", + "const": "deny-update-message-v2-parts", + "markdownDescription": "Denies the update_message_v2_parts command without any pre-configured scope." + }, { "description": "Enables the upsert_calendar command without any pre-configured scope.", "type": "string", @@ -859,10 +919,10 @@ "markdownDescription": "Denies the visit_session command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-onboarding-session-id`\n- `allow-thank-you-session-id`\n- `allow-upsert-session`\n- `allow-list-sessions`\n- `allow-get-session`\n- `allow-visit-session`\n- `allow-delete-session`\n- `allow-set-session-event`\n- `allow-session-add-participant`\n- `allow-session-remove-participant`\n- `allow-session-list-participants`\n- `allow-session-get-event`\n- `allow-get-words`\n- `allow-get-words-onboarding`\n- `allow-get-calendar`\n- `allow-list-calendars`\n- `allow-upsert-calendar`\n- `allow-toggle-calendar-selected`\n- `allow-list-templates`\n- `allow-upsert-template`\n- `allow-delete-template`\n- `allow-get-event`\n- `allow-list-events`\n- `allow-get-config`\n- `allow-set-config`\n- `allow-get-human`\n- `allow-delete-human`\n- `allow-upsert-human`\n- `allow-list-humans`\n- `allow-get-organization`\n- `allow-get-organization-by-user-id`\n- `allow-list-organizations`\n- `allow-list-organization-members`\n- `allow-upsert-organization`\n- `allow-delete-organization`\n- `allow-list-chat-groups`\n- `allow-list-chat-messages`\n- `allow-create-chat-group`\n- `allow-upsert-chat-message`\n- `allow-delete-chat-messages`\n- `allow-upsert-tag`\n- `allow-delete-tag`\n- `allow-list-all-tags`\n- `allow-list-session-tags`\n- `allow-assign-tag-to-session`\n- `allow-unassign-tag-from-session`\n- `allow-session-list-deleted-participant-ids`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-onboarding-session-id`\n- `allow-thank-you-session-id`\n- `allow-upsert-session`\n- `allow-list-sessions`\n- `allow-get-session`\n- `allow-visit-session`\n- `allow-delete-session`\n- `allow-set-session-event`\n- `allow-session-add-participant`\n- `allow-session-remove-participant`\n- `allow-session-list-participants`\n- `allow-session-get-event`\n- `allow-get-words`\n- `allow-get-words-onboarding`\n- `allow-get-calendar`\n- `allow-list-calendars`\n- `allow-upsert-calendar`\n- `allow-toggle-calendar-selected`\n- `allow-list-templates`\n- `allow-upsert-template`\n- `allow-delete-template`\n- `allow-get-event`\n- `allow-list-events`\n- `allow-get-config`\n- `allow-set-config`\n- `allow-get-human`\n- `allow-delete-human`\n- `allow-upsert-human`\n- `allow-list-humans`\n- `allow-get-organization`\n- `allow-get-organization-by-user-id`\n- `allow-list-organizations`\n- `allow-list-organization-members`\n- `allow-upsert-organization`\n- `allow-delete-organization`\n- `allow-list-chat-groups`\n- `allow-list-chat-messages`\n- `allow-create-chat-group`\n- `allow-upsert-chat-message`\n- `allow-delete-chat-messages`\n- `allow-list-conversations`\n- `allow-create-message-v2`\n- `allow-create-conversation`\n- `allow-list-messages-v2`\n- `allow-update-message-v2-parts`\n- `allow-upsert-tag`\n- `allow-delete-tag`\n- `allow-list-all-tags`\n- `allow-list-session-tags`\n- `allow-assign-tag-to-session`\n- `allow-unassign-tag-from-session`\n- `allow-session-list-deleted-participant-ids`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-onboarding-session-id`\n- `allow-thank-you-session-id`\n- `allow-upsert-session`\n- `allow-list-sessions`\n- `allow-get-session`\n- `allow-visit-session`\n- `allow-delete-session`\n- `allow-set-session-event`\n- `allow-session-add-participant`\n- `allow-session-remove-participant`\n- `allow-session-list-participants`\n- `allow-session-get-event`\n- `allow-get-words`\n- `allow-get-words-onboarding`\n- `allow-get-calendar`\n- `allow-list-calendars`\n- `allow-upsert-calendar`\n- `allow-toggle-calendar-selected`\n- `allow-list-templates`\n- `allow-upsert-template`\n- `allow-delete-template`\n- `allow-get-event`\n- `allow-list-events`\n- `allow-get-config`\n- `allow-set-config`\n- `allow-get-human`\n- `allow-delete-human`\n- `allow-upsert-human`\n- `allow-list-humans`\n- `allow-get-organization`\n- `allow-get-organization-by-user-id`\n- `allow-list-organizations`\n- `allow-list-organization-members`\n- `allow-upsert-organization`\n- `allow-delete-organization`\n- `allow-list-chat-groups`\n- `allow-list-chat-messages`\n- `allow-create-chat-group`\n- `allow-upsert-chat-message`\n- `allow-delete-chat-messages`\n- `allow-upsert-tag`\n- `allow-delete-tag`\n- `allow-list-all-tags`\n- `allow-list-session-tags`\n- `allow-assign-tag-to-session`\n- `allow-unassign-tag-from-session`\n- `allow-session-list-deleted-participant-ids`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-onboarding-session-id`\n- `allow-thank-you-session-id`\n- `allow-upsert-session`\n- `allow-list-sessions`\n- `allow-get-session`\n- `allow-visit-session`\n- `allow-delete-session`\n- `allow-set-session-event`\n- `allow-session-add-participant`\n- `allow-session-remove-participant`\n- `allow-session-list-participants`\n- `allow-session-get-event`\n- `allow-get-words`\n- `allow-get-words-onboarding`\n- `allow-get-calendar`\n- `allow-list-calendars`\n- `allow-upsert-calendar`\n- `allow-toggle-calendar-selected`\n- `allow-list-templates`\n- `allow-upsert-template`\n- `allow-delete-template`\n- `allow-get-event`\n- `allow-list-events`\n- `allow-get-config`\n- `allow-set-config`\n- `allow-get-human`\n- `allow-delete-human`\n- `allow-upsert-human`\n- `allow-list-humans`\n- `allow-get-organization`\n- `allow-get-organization-by-user-id`\n- `allow-list-organizations`\n- `allow-list-organization-members`\n- `allow-upsert-organization`\n- `allow-delete-organization`\n- `allow-list-chat-groups`\n- `allow-list-chat-messages`\n- `allow-create-chat-group`\n- `allow-upsert-chat-message`\n- `allow-delete-chat-messages`\n- `allow-list-conversations`\n- `allow-create-message-v2`\n- `allow-create-conversation`\n- `allow-list-messages-v2`\n- `allow-update-message-v2-parts`\n- `allow-upsert-tag`\n- `allow-delete-tag`\n- `allow-list-all-tags`\n- `allow-list-session-tags`\n- `allow-assign-tag-to-session`\n- `allow-unassign-tag-from-session`\n- `allow-session-list-deleted-participant-ids`" } ] } diff --git a/plugins/db/src/commands/chats_v2.rs b/plugins/db/src/commands/chats_v2.rs new file mode 100644 index 0000000000..3f5e3aecf3 --- /dev/null +++ b/plugins/db/src/commands/chats_v2.rs @@ -0,0 +1,102 @@ +use hypr_db_user::{ChatConversation, ChatMessageV2}; + +#[tauri::command] +#[specta::specta] +#[tracing::instrument(skip(state))] +pub async fn create_conversation( + state: tauri::State<'_, crate::ManagedState>, + conversation: ChatConversation, +) -> Result { + let guard = state.lock().await; + + let db = guard + .db + .as_ref() + .ok_or(crate::Error::NoneDatabase) + .map_err(|e| e.to_string())?; + + db.create_conversation(conversation) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +#[tracing::instrument(skip(state))] +pub async fn list_conversations( + state: tauri::State<'_, crate::ManagedState>, + session_id: String, +) -> Result, String> { + let guard = state.lock().await; + + let db = guard + .db + .as_ref() + .ok_or(crate::Error::NoneDatabase) + .map_err(|e| e.to_string())?; + + db.list_conversations(session_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +#[tracing::instrument(skip(state))] +pub async fn create_message_v2( + state: tauri::State<'_, crate::ManagedState>, + message: ChatMessageV2, +) -> Result { + let guard = state.lock().await; + + let db = guard + .db + .as_ref() + .ok_or(crate::Error::NoneDatabase) + .map_err(|e| e.to_string())?; + + db.create_message_v2(message) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +#[tracing::instrument(skip(state))] +pub async fn list_messages_v2( + state: tauri::State<'_, crate::ManagedState>, + conversation_id: String, +) -> Result, String> { + let guard = state.lock().await; + + let db = guard + .db + .as_ref() + .ok_or(crate::Error::NoneDatabase) + .map_err(|e| e.to_string())?; + + db.list_messages_v2(conversation_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +#[tracing::instrument(skip(state))] +pub async fn update_message_v2_parts( + state: tauri::State<'_, crate::ManagedState>, + id: String, + parts: String, +) -> Result<(), String> { + let guard = state.lock().await; + + let db = guard + .db + .as_ref() + .ok_or(crate::Error::NoneDatabase) + .map_err(|e| e.to_string())?; + + db.update_message_v2_parts(id, parts) + .await + .map_err(|e| e.to_string()) +} diff --git a/plugins/db/src/commands/mod.rs b/plugins/db/src/commands/mod.rs index 4002f26347..57141d09c3 100644 --- a/plugins/db/src/commands/mod.rs +++ b/plugins/db/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod calendars; pub mod chats; +pub mod chats_v2; pub mod configs; pub mod events; pub mod humans; diff --git a/plugins/db/src/lib.rs b/plugins/db/src/lib.rs index 86a1c8b012..c05f7eb5a6 100644 --- a/plugins/db/src/lib.rs +++ b/plugins/db/src/lib.rs @@ -70,6 +70,11 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::tags::unassign_tag_from_session, commands::tags::upsert_tag, commands::tags::delete_tag, + commands::chats_v2::create_conversation, + commands::chats_v2::list_conversations, + commands::chats_v2::create_message_v2, + commands::chats_v2::list_messages_v2, + commands::chats_v2::update_message_v2_parts, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) } diff --git a/plugins/local-llm/js/bindings.gen.ts b/plugins/local-llm/js/bindings.gen.ts index fe69343593..0720eedd63 100644 --- a/plugins/local-llm/js/bindings.gen.ts +++ b/plugins/local-llm/js/bindings.gen.ts @@ -63,6 +63,11 @@ async generateTags(ctx: Partial<{ [key in string]: JsonValue }>) : Promise({ +llmEvent: "plugin:local-llm:llm-event" +}) /** user-defined constants **/ @@ -72,6 +77,7 @@ async generateTags(ctx: Partial<{ [key in string]: JsonValue }>) : Promise +export type LLMEvent = { progress: number } export type ModelInfo = { key: SupportedModel; name: string; description: string; size_bytes: number } export type ModelSelection = { type: "Predefined"; content: { key: SupportedModel } } | { type: "Custom"; content: { path: string } } export type SupportedModel = "Llama3p2_3bQ4" | "Gemma3_4bQ4" | "HyprLLM" diff --git a/plugins/local-llm/src/events.rs b/plugins/local-llm/src/events.rs index e6e8f9a3b8..a28e7a4def 100644 --- a/plugins/local-llm/src/events.rs +++ b/plugins/local-llm/src/events.rs @@ -30,3 +30,9 @@ pub fn on_event(app: &tauri::AppHandle, event: &tauri::Run _ => {} } } + +#[derive(serde::Serialize, Clone, specta::Type, tauri_specta::Event)] +pub enum LLMEvent { + #[serde(rename = "progress")] + Progress(f64), +} diff --git a/plugins/local-llm/src/ext/plugin.rs b/plugins/local-llm/src/ext/plugin.rs index f157b8b17e..401b81c165 100644 --- a/plugins/local-llm/src/ext/plugin.rs +++ b/plugins/local-llm/src/ext/plugin.rs @@ -2,6 +2,7 @@ use std::{future::Future, path::PathBuf}; use tauri::{ipc::Channel, Manager, Runtime}; use tauri_plugin_store2::StorePluginExt; +use tauri_specta::Event; use hypr_download_interface::DownloadProgress; use hypr_file::download_file_parallel; @@ -192,7 +193,12 @@ impl> LocalLlmPluginExt for T { .build(); let state = self.state::(); - let server_state = crate::ServerState::new(model_manager); + let handle = self.app_handle().clone(); + let emitter = move |event: crate::LLMEvent| { + let _ = event.emit(&handle); + }; + + let server_state = crate::ServerState::new(emitter, model_manager); let server = crate::server::run_server(server_state).await?; tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/plugins/local-llm/src/lib.rs b/plugins/local-llm/src/lib.rs index 03d9b94a63..2132f1c6d4 100644 --- a/plugins/local-llm/src/lib.rs +++ b/plugins/local-llm/src/lib.rs @@ -40,6 +40,7 @@ pub struct State { fn make_specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() .plugin_name(PLUGIN_NAME) + .events(tauri_specta::collect_events![events::LLMEvent]) .commands(tauri_specta::collect_commands![ commands::models_dir::, commands::list_supported_model, @@ -67,7 +68,9 @@ pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new(PLUGIN_NAME) .invoke_handler(specta_builder.invoke_handler()) - .setup(|app, _api| { + .setup(move |app, _api| { + specta_builder.mount_events(app); + let data_dir = app.path().app_data_dir().unwrap(); let models_dir = app.models_dir(); diff --git a/plugins/local-llm/src/server.rs b/plugins/local-llm/src/server.rs index 9105f3d5c2..841c76baf4 100644 --- a/plugins/local-llm/src/server.rs +++ b/plugins/local-llm/src/server.rs @@ -3,7 +3,7 @@ use std::pin::Pin; use std::sync::{Arc, Mutex}; use async_openai::types::{ - ChatChoice, ChatChoiceStream, ChatCompletionMessageToolCallChunk, ChatCompletionRequestMessage, + ChatChoice, ChatChoiceStream, ChatCompletionMessageToolCallChunk, ChatCompletionResponseMessage, ChatCompletionStreamResponseDelta, ChatCompletionToolType, CreateChatCompletionRequest, CreateChatCompletionResponse, CreateChatCompletionStreamResponse, FunctionCallStream, Role, @@ -22,7 +22,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tower_http::cors::{self, CorsLayer}; -use hypr_llm::ModelManager; +use crate::{events::LLMEvent, ModelManager}; #[derive(Clone)] pub struct ServerHandle { @@ -38,13 +38,18 @@ impl ServerHandle { #[derive(Clone)] pub struct ServerState { + pub emitter: Arc, pub model_manager: ModelManager, pub cancellation_tokens: Arc>>, } impl ServerState { - pub fn new(model_manager: ModelManager) -> Self { + pub fn new( + emitter: impl Fn(LLMEvent) + 'static + Send + Sync, + model_manager: ModelManager, + ) -> Self { Self { + emitter: Arc::new(emitter), model_manager, cancellation_tokens: Arc::new(Mutex::new(Vec::new())), } @@ -122,51 +127,12 @@ async fn chat_completions( AxumState(state): AxumState, Json(request): Json, ) -> Result { - // TODO - let request = { - let mut r = request.clone(); - r.messages = r - .messages - .iter() - .filter_map(|m| match m { - ChatCompletionRequestMessage::Assistant(am) => { - let mut cloned_am = am.clone(); - let filtered_tool_calls = cloned_am.tool_calls.as_ref().map(|tc| { - tc.iter() - .filter(|c| c.function.name != "progress_update") - .cloned() - .collect::>() - }); - - let new_tool_calls = match filtered_tool_calls { - Some(calls) if calls.is_empty() => None, - Some(calls) => Some(calls), - None => None, - }; - - cloned_am.tool_calls = new_tool_calls; - Some(ChatCompletionRequestMessage::Assistant(cloned_am)) - } - ChatCompletionRequestMessage::Tool(tm) => { - if tm.tool_call_id == "progress_update" { - None - } else { - Some(m.clone()) - } - } - _ => Some(m.clone()), - }) - .collect(); - - r - }; - let response = if request.model == "mock-onboarding" { let provider = MockProvider::default(); tracing::info!("using_mock_provider"); provider.chat_completions(request, &state).await } else { - let provider = LocalProvider::new(state.model_manager.clone()); + let provider = LocalProvider::new(state.emitter.clone(), state.model_manager.clone()); tracing::info!("using_local_provider"); provider.chat_completions(request, &state).await }; @@ -177,12 +143,16 @@ async fn chat_completions( } struct LocalProvider { + emitter: Arc, model_manager: ModelManager, } impl LocalProvider { - fn new(model_manager: ModelManager) -> Self { - Self { model_manager } + fn new(emitter: Arc, model_manager: ModelManager) -> Self { + Self { + emitter, + model_manager, + } } async fn chat_completions( @@ -193,11 +163,19 @@ impl LocalProvider { let model = self.model_manager.get_model().await?; tracing::info!("loaded_model: {:?}", model.name); - build_chat_completion_response(&request, || { - let (stream, token) = Self::build_stream(&model, &request)?; - state.register_token(token.clone()); - Ok(stream) - }) + let emitter = self.emitter.clone(); + + build_chat_completion_response( + &request, + || { + let (stream, token) = Self::build_stream(&model, &request)?; + state.register_token(token.clone()); + Ok(stream) + }, + move |v| { + emitter(LLMEvent::Progress(v)); + }, + ) .await } @@ -237,17 +215,7 @@ impl LocalProvider { } }; - let tools = request - .tools - .as_ref() - .map(|tools| { - tools - .iter() - .filter(|tool| tool.function.name != "progress_update") - .cloned() - .collect::>() - }) - .filter(|tools| !tools.is_empty()); + let tools = request.tools.clone(); let request = hypr_llama::LlamaRequest { messages, @@ -299,11 +267,15 @@ impl MockProvider { state: &ServerState, ) -> Result { let content = crate::ONBOARDING_ENHANCED_MD; - build_chat_completion_response(&request, || { - let (stream, token) = Self::build_stream(&content); - state.register_token(token.clone()); - Ok(stream) - }) + build_chat_completion_response( + &request, + || { + let (stream, token) = Self::build_stream(&content); + state.register_token(token.clone()); + Ok(stream) + }, + |_v| {}, + ) .await } @@ -348,6 +320,7 @@ async fn build_chat_completion_response( Pin + Send>>, crate::Error, >, + progress_fn: impl Fn(f64) + Send + Sync + 'static, ) -> Result { let id = uuid::Uuid::new_v4().to_string(); let created = std::time::SystemTime::now() @@ -508,34 +481,9 @@ async fn build_chat_completion_response( })) } }, - StreamEvent::Progress(progress) => { - Some(Ok(CreateChatCompletionStreamResponse { - choices: vec![ChatChoiceStream { - index: 0, - delta: ChatCompletionStreamResponseDelta { - tool_calls: Some(vec![ - ChatCompletionMessageToolCallChunk { - index: index.try_into().unwrap_or(0), - id: Some("progress_update".to_string()), - r#type: Some(ChatCompletionToolType::Function), - function: Some(FunctionCallStream { - name: Some("progress_update".to_string()), - arguments: Some( - serde_json::to_string(&serde_json::json!({ - "progress": progress - })) - .unwrap(), - ), - }), - }, - ]), - ..delta_template - }, - finish_reason: None, - logprobs: None, - }], - ..response_template - })) + StreamEvent::Progress(v) => { + progress_fn(v); + None } } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d1fdea19b..c16e15c2aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@ai-sdk/react': + specifier: ^2.0.30 + version: 2.0.48(react@19.1.1)(zod@4.1.5) devDependencies: turbo: specifier: ^2.5.6 @@ -921,6 +925,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/gateway@1.0.25': + resolution: {integrity: sha512-eI/6LLmn1tWFzuhjxgcPEqUFXwLjyRuGFrwkCoqLaTKe/qMYBEAV3iddnGUM0AV+Hp4NEykzP4ly5tibOLDMXw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 + '@ai-sdk/openai-compatible@1.0.15': resolution: {integrity: sha512-i4TzohCxuFzBSdRNPa9eNFW6AYDZ5itbxz+rJa2kpNTMYqHgqKPGzet3X6eLIUVntA10icrqhWT+hUhxXZIS9Q==} engines: {node: '>=18'} @@ -933,10 +943,26 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/provider-utils@3.0.9': + resolution: {integrity: sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 + '@ai-sdk/provider@2.0.0': resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} + '@ai-sdk/react@2.0.48': + resolution: {integrity: sha512-09Wo0xayrIiIrQCNca3XSvTA2hTGk+CZVALeuLa+OjegKcpM0Zs/u1lcsW8tZd3dCPX3j59Y0cQrCZwxeKyhhA==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + zod: ^3.25.76 || ^4 + peerDependenciesMeta: + zod: + optional: true + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4014,6 +4040,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + ai@5.0.48: + resolution: {integrity: sha512-+oYhbN3NGRXayGfTFI8k1Fu4rhiJcQ0mbgiAOJGFkzvCxunRRQu5cyDl7y6cHNTj1QvHmIBROK5u655Ss2oI0g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -8842,6 +8874,10 @@ packages: throttleit@1.0.1: resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -9642,6 +9678,12 @@ snapshots: '@ai-sdk/provider-utils': 3.0.8(zod@4.1.5) zod: 4.1.5 + '@ai-sdk/gateway@1.0.25(zod@4.1.5)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.9(zod@4.1.5) + zod: 4.1.5 + '@ai-sdk/openai-compatible@1.0.15(zod@4.1.5)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -9655,10 +9697,27 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.1.5 + '@ai-sdk/provider-utils@3.0.9(zod@4.1.5)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 4.1.5 + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 + '@ai-sdk/react@2.0.48(react@19.1.1)(zod@4.1.5)': + dependencies: + '@ai-sdk/provider-utils': 3.0.9(zod@4.1.5) + ai: 5.0.48(zod@4.1.5) + react: 19.1.1 + swr: 2.3.6(react@19.1.1) + throttleit: 2.1.0 + optionalDependencies: + zod: 4.1.5 + '@alloc/quick-lru@5.2.0': {} '@ant-design/colors@7.2.1': @@ -13264,6 +13323,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.1.5 + ai@5.0.48(zod@4.1.5): + dependencies: + '@ai-sdk/gateway': 1.0.25(zod@4.1.5) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.9(zod@4.1.5) + '@opentelemetry/api': 1.9.0 + zod: 4.1.5 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -19219,6 +19286,12 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.5.0(react@18.3.1) + swr@2.3.6(react@19.1.1): + dependencies: + dequal: 2.0.3 + react: 19.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) + symbol-tree@3.2.4: optional: true @@ -19350,6 +19423,8 @@ snapshots: throttleit@1.0.1: optional: true + throttleit@2.1.0: {} + through@2.3.8: {} tiny-inflate@1.0.3: {} @@ -19776,6 +19851,10 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.5.0(react@19.1.1): + dependencies: + react: 19.1.1 + userhome@1.0.1: {} util-deprecate@1.0.2: {}