diff --git a/apps/desktop/src/components/editor-area/index.tsx b/apps/desktop/src/components/editor-area/index.tsx index 47b9af0262..0dab6bc90d 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -477,6 +477,9 @@ export function useEnhanceMutation({ ? provider.languageModel("onboardingModel") : provider.languageModel("defaultModel"); + console.log("model: ", model); + console.log("provider: ", provider); + if (sessionId !== onboardingSessionId) { analyticsCommands.event({ event: "normal_enhance_start", 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 169a243899..db0c742984 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,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ChatMessage } from "./chat-message"; import { Message } from "./types"; @@ -7,14 +7,93 @@ interface ChatMessagesViewProps { sessionTitle?: string; hasEnhancedNote?: boolean; onApplyMarkdown?: (markdownContent: string) => void; + isGenerating?: boolean; + isStreamingText?: boolean; } -export function ChatMessagesView({ messages, sessionTitle, hasEnhancedNote, onApplyMarkdown }: ChatMessagesViewProps) { +function ThinkingIndicator() { + return ( + <> + +
+ Thinking + . + . + . +
+ + ); +} + +export function ChatMessagesView( + { messages, sessionTitle, hasEnhancedNote, onApplyMarkdown, isGenerating, isStreamingText }: ChatMessagesViewProps, +) { const messagesEndRef = useRef(null); + const [showThinking, setShowThinking] = useState(false); + const thinkingTimeoutRef = useRef(null); + + const shouldShowThinking = () => { + if (!isGenerating) { + return false; + } + + if (messages.length === 0) { + return true; + } + + const lastMessage = messages[messages.length - 1]; + if (lastMessage.isUser) { + return true; + } + + if (!lastMessage.isUser && !isStreamingText) { + return true; + } + + return false; + }; + + useEffect(() => { + const shouldShow = shouldShowThinking(); + + if (thinkingTimeoutRef.current) { + clearTimeout(thinkingTimeoutRef.current); + thinkingTimeoutRef.current = null; + } + + if (shouldShow) { + thinkingTimeoutRef.current = setTimeout(() => { + setShowThinking(true); + }, 200); + } else { + setShowThinking(false); + } + + return () => { + if (thinkingTimeoutRef.current) { + clearTimeout(thinkingTimeoutRef.current); + } + }; + }, [isGenerating, isStreamingText, messages]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + }, [messages, showThinking]); return (
@@ -27,6 +106,10 @@ export function ChatMessagesView({ messages, sessionTitle, hasEnhancedNote, onAp onApplyMarkdown={onApplyMarkdown} /> ))} + + {/* Thinking indicator with debounce - no flicker! */} + {showThinking && } +
); 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 index 17dcbc156e..6d7acb4547 100644 --- a/apps/desktop/src/components/right-panel/components/chat/message-content.tsx +++ b/apps/desktop/src/components/right-panel/components/chat/message-content.tsx @@ -1,5 +1,6 @@ import { commands as miscCommands } from "@hypr/plugin-misc"; import Renderer from "@hypr/tiptap/renderer"; +import { PencilRuler } from "lucide-react"; import { useEffect, useState } from "react"; import { MarkdownCard } from "./markdown-card"; import { Message } from "./types"; @@ -122,32 +123,87 @@ function MarkdownText({ content }: { content: string }) { } export function MessageContent({ message, sessionTitle, hasEnhancedNote, onApplyMarkdown }: MessageContentProps) { - if (message.content === "Generating...") { + if (message.type === "tool-start") { return ( - <> - -
- Thinking - . - . - . +
+
+ + + Called tool: {message.content} +
- +
+ ); + } + + if (message.type === "tool-result") { + return ( +
+
+ + + {message.content} + +
+
+ ); + } + + if (message.type === "tool-error") { + return ( +
+
+ + + Tool Error: {message.content} + +
+
); } diff --git a/apps/desktop/src/components/right-panel/components/chat/types.ts b/apps/desktop/src/components/right-panel/components/chat/types.ts index a60952276e..21b4d7c7fb 100644 --- a/apps/desktop/src/components/right-panel/components/chat/types.ts +++ b/apps/desktop/src/components/right-panel/components/chat/types.ts @@ -10,6 +10,7 @@ export interface Message { parts?: MessagePart[]; isUser: boolean; timestamp: Date; + type: "text-delta" | "tool-start" | "tool-result" | "tool-error" | "generating"; } export type ChatSession = { diff --git a/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts b/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts index c90e00ee2a..8b79c31974 100644 --- a/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts +++ b/apps/desktop/src/components/right-panel/hooks/useChatLogic.ts @@ -1,17 +1,25 @@ import { message } from "@tauri-apps/plugin-dialog"; -import { useState } from "react"; +import { useRef, useState } from "react"; 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 { commands as templateCommands } from "@hypr/plugin-template"; -import { modelProvider, stepCountIs, streamText, tool } from "@hypr/utils/ai"; +import { + dynamicTool, + experimental_createMCPClient, + modelProvider, + stepCountIs, + streamText, + tool, +} from "@hypr/utils/ai"; import { useSessions } from "@hypr/utils/contexts"; import { useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; import type { ActiveEntityInfo, Message } from "../types/chat-types"; +import { prepareMessageHistory } from "../utils/chat-utils"; import { parseMarkdownBlocks } from "../utils/markdown-parser"; interface UseChatLogicProps { @@ -46,6 +54,8 @@ export function useChatLogic({ llmConnectionQuery, }: UseChatLogicProps) { const [isGenerating, setIsGenerating] = useState(false); + const [isStreamingText, setIsStreamingText] = useState(false); + const isGeneratingRef = useRef(false); const sessions = useSessions((state) => state.sessions); const { getLicense } = useLicense(); const queryClient = useQueryClient(); @@ -66,172 +76,11 @@ export function useChatLogic({ const html = await miscCommands.opinionatedMdToHtml(markdownContent); sessionStore.getState().updateEnhancedNote(html); - - console.log("Applied markdown content to enhanced note"); } catch (error) { console.error("Failed to apply markdown content:", error); } }; - const prepareMessageHistory = async ( - messages: Message[], - currentUserMessage?: string, - mentionedContent?: Array<{ id: string; type: string; label: string }>, - modelId?: string, - ) => { - const refetchResult = await sessionData.refetch(); - let freshSessionData = refetchResult.data; - - const { type } = await connectorCommands.getLlmConnection(); - - const participants = sessionId ? await dbCommands.sessionListParticipants(sessionId) : []; - - const calendarEvent = sessionId ? await dbCommands.sessionGetEvent(sessionId) : null; - - const currentDateTime = new Date().toLocaleString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - - const eventInfo = calendarEvent - ? `${calendarEvent.name} (${calendarEvent.start_date} - ${calendarEvent.end_date})${ - calendarEvent.note ? ` - ${calendarEvent.note}` : "" - }` - : ""; - - const systemContent = await templateCommands.render("ai_chat.system", { - session: freshSessionData, - words: JSON.stringify(freshSessionData?.words || []), - title: freshSessionData?.title, - enhancedContent: freshSessionData?.enhancedContent, - rawContent: freshSessionData?.rawContent, - preMeetingContent: freshSessionData?.preMeetingContent, - type: type, - date: currentDateTime, - participants: participants, - event: eventInfo, - modelId: modelId, - }); - - 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, - }); - }); - - if (mentionedContent && mentionedContent.length > 0) { - currentUserMessage += - "[[From here is an automatically appended content from the mentioned notes & people, not what the user wrote. Use this only as a reference for more context. Your focus should always be the current meeting user is viewing]]" - + "\n\n"; - } - - if (mentionedContent && mentionedContent.length > 0) { - const noteContents: string[] = []; - - 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; - } - - noteContents.push(`\n\n--- Content from the note"${mention.label}" ---\n${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 => - 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) + "..."; - } - - humanContent += `- "${session.title || "Untitled"}": ${briefContent}\n`; - } - } - } - } catch (error) { - console.error(`Error fetching notes for person "${humanData.full_name}":`, error); - } - } - - if (humanData) { - noteContents.push(`\n\n--- Content about the person "${mention.label}" ---\n${humanContent}`); - } - } - } catch (error) { - console.error(`Error fetching content for "${mention.label}":`, error); - } - } - - if (noteContents.length > 0) { - currentUserMessage = currentUserMessage + noteContents.join(""); - } - } - - if (currentUserMessage) { - conversationHistory.push({ - role: "user" as const, - content: currentUserMessage, - }); - } - - return conversationHistory; - }; - const processUserMessage = async ( content: string, analyticsEvent: string, @@ -241,7 +90,9 @@ export function useChatLogic({ return; } - if (messages.length >= 6 && !getLicense.data?.valid) { + const userMessageCount = messages.filter(msg => msg.isUser).length; + + if (userMessageCount >= 3 && !getLicense.data?.valid) { if (userId) { await analyticsCommands.event({ event: "pro_license_required_chat", @@ -267,14 +118,16 @@ export function useChatLogic({ } setIsGenerating(true); + isGeneratingRef.current = true; const groupId = await getChatGroupId(); const userMessage: Message = { - id: Date.now().toString(), + id: crypto.randomUUID(), content: content, isUser: true, timestamp: new Date(), + type: "text-delta", }; setMessages((prev) => [...prev, userMessage]); @@ -286,43 +139,101 @@ export function useChatLogic({ created_at: userMessage.timestamp.toISOString(), role: "User", content: userMessage.content.trim(), + type: "text-delta", }); - // Declare aiMessageId outside try block so it's accessible in catch - const aiMessageId = (Date.now() + 1).toString(); + const aiMessageId = crypto.randomUUID(); try { const provider = await modelProvider(); const model = provider.languageModel("defaultModel"); - const aiMessage: Message = { - id: aiMessageId, - content: "Generating...", - isUser: false, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, aiMessage]); - await queryClient.invalidateQueries({ queryKey: ["llm-connection"] }); await new Promise(resolve => setTimeout(resolve, 100)); - const { type } = await connectorCommands.getLlmConnection(); + const llmConnection = await connectorCommands.getLlmConnection(); + const { type } = llmConnection; + const apiBase = llmConnection.connection?.api_base; + + let newMcpTools: Record = {}; + let mcpToolsArray: any[] = []; + const allMcpClients: any[] = []; + + const shouldUseMcpTools = type !== "HyprLocal" + && (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")); + + if (shouldUseMcpTools) { + const mcpServers = await mcpCommands.getServers(); + const enabledSevers = mcpServers.filter((server) => server.enabled); + + 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", + })) + : []; + } - const { textStream } = streamText({ + const { fullStream } = streamText({ model, - messages: await prepareMessageHistory(messages, content, mentionedContent, model.modelId), + messages: await prepareMessageHistory( + messages, + content, + mentionedContent, + model.modelId, + mcpToolsArray, + sessionData, + sessionId, + userId, + apiBase, + ), ...(type === "HyprLocal" && { tools: { update_progress: tool({ inputSchema: z.any() }), }, }), - ...((type !== "HyprLocal" - && (model.modelId === "gpt-4.1" || model.modelId === "openai/gpt-4.1" - || model.modelId === "anthropic/claude-4-sonnet" - || model.modelId === "openai/gpt-4o" - || model.modelId === "gpt-4o")) && { + ...(shouldUseMcpTools && { stopWhen: stepCountIs(3), tools: { + ...newMcpTools, search_sessions_multi_keywords: tool({ description: "Search for sessions (meeting notes) with multiple keywords. The keywords should be the most important things that the user is talking about. This could be either topics, people, or company names.", @@ -382,66 +293,204 @@ export function useChatLogic({ onError: (error) => { console.error("On Error Catch:", error); setIsGenerating(false); + isGeneratingRef.current = false; throw error; }, + onFinish: () => { + for (const client of allMcpClients) { + client.close(); + } + }, }); let aiResponse = ""; + let didInitializeAiResponse = false; + let currentAiTextMessageId: string | null = null; + let lastChunkType: string | null = null; - for await (const chunk of textStream) { - aiResponse += chunk; + 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 - const parts = parseMarkdownBlocks(aiResponse); + await new Promise(resolve => setTimeout(resolve, 50)); + } - setMessages((prev) => - prev.map(msg => - msg.id === aiMessageId - ? { - ...msg, + 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, - parts: parts, + isUser: false, + timestamp: new Date(), + type: "text-delta", + parts, + }; + + currentAiTextMessageId = newTextMessage.id; + return [...prev, newTextMessage]; + } + }); + } + + if (chunk.type === "tool-call" && !(chunk.toolName === "update_progress" && 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(), + }); + } catch (error) { + console.error("Failed to save AI text:", error); } - : msg - ) - ); + }; + saveAiText(); + currentAiTextMessageId = null; // Reset + } + + didInitializeAiResponse = false; + + const toolStartMessage: Message = { + id: crypto.randomUUID(), + content: `${chunk.toolName}`, + isUser: false, + timestamp: new Date(), + type: "tool-start", + }; + 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", + }); + } + + if (chunk.type === "tool-result" && !(chunk.toolName === "update_progress" && 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", + }); + } + + if (chunk.type === "tool-error" && !(chunk.toolName === "update_progress" && 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", + }); + } + + lastChunkType = chunk.type; } - await dbCommands.upsertChatMessage({ - id: aiMessageId, - group_id: groupId, - created_at: new Date().toISOString(), - role: "Assistant", - content: aiResponse.trim(), - }); + 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(), + }); + } setIsGenerating(false); + setIsStreamingText(false); + isGeneratingRef.current = false; } catch (error) { console.error("AI error:", error); - const errorMessage = (error as any)?.error || "Unknown error"; + const errorMsg = (error as any)?.error || "Unknown error"; let finalErrorMesage = ""; - if (String(errorMessage).includes("too large")) { + if (String(errorMsg).includes("too large")) { finalErrorMesage = "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" + errorMessage; + + "\n\n" + errorMsg; } else { - finalErrorMesage = "Sorry, I encountered an error. Please try again. " + "\n\n" + errorMessage; + finalErrorMesage = "Sorry, I encountered an error. Please try again. " + "\n\n" + errorMsg; } setIsGenerating(false); + setIsStreamingText(false); + isGeneratingRef.current = false; - setMessages((prev) => - prev.map(msg => - msg.id === aiMessageId - ? { - ...msg, - content: finalErrorMesage, - } - : msg - ) - ); + // Create error message + const errorMessage: Message = { + id: aiMessageId, + content: finalErrorMesage, + isUser: false, + timestamp: new Date(), + type: "text-delta", + }; + + setMessages((prev) => [...prev, errorMessage]); await dbCommands.upsertChatMessage({ id: aiMessageId, @@ -449,6 +498,7 @@ export function useChatLogic({ created_at: new Date().toISOString(), role: "Assistant", content: finalErrorMesage, + type: "text-delta", }); } }; @@ -474,6 +524,7 @@ export function useChatLogic({ return { isGenerating, + isStreamingText, handleSubmit, handleQuickAction, handleApplyMarkdown, diff --git a/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts b/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts index 5f594e237c..38ef252506 100644 --- a/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts +++ b/apps/desktop/src/components/right-panel/hooks/useChatQueries.ts @@ -78,14 +78,13 @@ export function useChatQueries({ return []; } - console.log("🔍 DEBUG: Loading messages for chat group =", currentChatGroupId); - const dbMessages = await dbCommands.listChatMessages(currentChatGroupId); return dbMessages.map(msg => ({ 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, })); }, @@ -97,9 +96,15 @@ export function useChatQueries({ prevIsGenerating.current = isGenerating || false; } - if (chatMessagesQuery.data && !isGenerating && !justFinishedGenerating) { - setMessages(chatMessagesQuery.data); - setHasChatStarted(chatMessagesQuery.data.length > 0); + 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]); diff --git a/apps/desktop/src/components/right-panel/hooks/useMcpTools.ts b/apps/desktop/src/components/right-panel/hooks/useMcpTools.ts new file mode 100644 index 0000000000..f7c3799629 --- /dev/null +++ b/apps/desktop/src/components/right-panel/hooks/useMcpTools.ts @@ -0,0 +1,119 @@ +import { commands as mcpCommands } from "@hypr/plugin-mcp"; +import { dynamicTool, experimental_createMCPClient } from "@hypr/utils/ai"; +import { useQuery } from "@tanstack/react-query"; +import z from "zod"; + +const mcpClientCache = new Map(); + +export function useMcpTools() { + return useQuery({ + queryKey: ["mcp-tools"], + queryFn: async () => { + console.log("[MCP] Starting to fetch MCP tools at", new Date().toISOString()); + console.log("[MCP] Cache status:", { + cachedClients: mcpClientCache.size, + cachedUrls: Array.from(mcpClientCache.keys()), + }); + + const servers = await mcpCommands.getServers(); + console.log("[MCP] Found servers:", servers.length, servers.map(s => ({ url: s.url, enabled: s.enabled }))); + + const enabledServers = servers.filter((server) => server.enabled); + console.log("[MCP] Enabled servers:", enabledServers.length); + + if (enabledServers.length === 0) { + console.log("[MCP] No enabled servers, returning empty object"); + return {}; + } + + const allTools: Record = {}; + + for (const server of enabledServers) { + const startTime = Date.now(); + console.log(`[MCP] Processing server: ${server.url}`); + + try { + let mcpClient = mcpClientCache.get(server.url); + + if (!mcpClient) { + console.log(`[MCP] Creating new client for ${server.url} (not in cache)`); + mcpClient = await experimental_createMCPClient({ + transport: { + type: "sse", + url: server.url, + onerror: (error) => { + console.error(`[MCP] Error from ${server.url}:`, error); + }, + onclose: () => { + console.log(`[MCP] Connection closed for ${server.url}`); + }, + }, + }); + + mcpClientCache.set(server.url, mcpClient); + console.log(`[MCP] Client created and cached for ${server.url}`); + } else { + console.log(`[MCP] Using cached client for ${server.url}`); + } + + console.log(`[MCP] Fetching tools from ${server.url}...`); + const tools = await mcpClient.tools(); + const toolCount = Object.keys(tools).length; + console.log(`[MCP] Received ${toolCount} tools from ${server.url}`); + + for (const [toolName, tool] of Object.entries(tools as Record)) { + allTools[toolName] = dynamicTool({ + description: tool.description, + inputSchema: tool.inputSchema || z.any(), + execute: tool.execute, + toModelOutput: (result: any) => { + console.log(`[MCP] Tool result:`, result); + return result; + }, + }); + } + + const elapsed = Date.now() - startTime; + console.log(`[MCP] Successfully processed ${server.url} in ${elapsed}ms`); + } catch (error) { + const elapsed = Date.now() - startTime; + console.error(`[MCP] Error fetching tools from ${server.url} after ${elapsed}ms:`, error); + + if (error instanceof Error) { + console.error(`[MCP] Error name: ${error.name}`); + console.error(`[MCP] Error message: ${error.message}`); + console.error(`[MCP] Error stack:`, error.stack); + } + + mcpClientCache.delete(server.url); + console.log(`[MCP] Removed failed client from cache for ${server.url}`); + } + } + + const totalTools = Object.keys(allTools).length; + console.log(`[MCP] Completed fetching. Total tools loaded: ${totalTools}`); + console.log(`[MCP] Tool names:`, Object.keys(allTools)); + + return allTools; + }, + + staleTime: 0, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} + +export function clearMcpClientCache() { + mcpClientCache.clear(); +} + +export function closeMcpClients() { + for (const client of mcpClientCache.values()) { + try { + client.close(); + } catch (error) { + console.error(`[MCP] Error closing client:`, error); + } + } +} 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 4f8e9182a8..324cb37259 100644 --- a/apps/desktop/src/components/right-panel/utils/chat-utils.ts +++ b/apps/desktop/src/components/right-panel/utils/chat-utils.ts @@ -1,3 +1,8 @@ +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"; + export const formatDate = (date: Date) => { const now = new Date(); const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); @@ -26,3 +31,171 @@ export const focusInput = (chatInputRef: React.RefObject) = chatInputRef.current.focus(); } }; + +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, +) => { + const refetchResult = await sessionData?.refetch(); + let freshSessionData = refetchResult?.data; + + const { type } = await connectorCommands.getLlmConnection(); + + const participants = sessionId ? await dbCommands.sessionListParticipants(sessionId) : []; + + const calendarEvent = sessionId ? await dbCommands.sessionGetEvent(sessionId) : null; + + const currentDateTime = new Date().toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + + const eventInfo = calendarEvent + ? `${calendarEvent.name} (${calendarEvent.start_date} - ${calendarEvent.end_date})${ + calendarEvent.note ? ` - ${calendarEvent.note}` : "" + }` + : ""; + + const systemContent = await templateCommands.render("ai_chat.system", { + session: freshSessionData, + words: JSON.stringify(freshSessionData?.words || []), + title: freshSessionData?.title, + enhancedContent: freshSessionData?.enhancedContent, + rawContent: freshSessionData?.rawContent, + preMeetingContent: freshSessionData?.preMeetingContent, + type: type, + date: currentDateTime, + participants: participants, + event: eventInfo, + modelId: modelId, + mcpTools: mcpToolsArray, + apiBase: apiBase, + }); + + console.log("system prompt", systemContent); + + 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, + }); + }); + + if (mentionedContent && mentionedContent.length > 0) { + currentUserMessage += + "[[From here is an automatically appended content from the mentioned notes & people, not what the user wrote. Use this only as a reference for more context. Your focus should always be the current meeting user is viewing]]" + + "\n\n"; + } + + if (mentionedContent && mentionedContent.length > 0) { + const noteContents: string[] = []; + + 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; + } + + noteContents.push(`\n\n--- Content from the note"${mention.label}" ---\n${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) + "..."; + } + + humanContent += `- "${session.title || "Untitled"}": ${briefContent}\n`; + } + } + } + } catch (error) { + console.error(`Error fetching notes for person "${humanData.full_name}":`, error); + } + } + + if (humanData) { + noteContents.push(`\n\n--- Content about the person "${mention.label}" ---\n${humanContent}`); + } + } + } catch (error) { + console.error(`Error fetching content for "${mention.label}":`, error); + } + } + + if (noteContents.length > 0) { + currentUserMessage = currentUserMessage + noteContents.join(""); + } + } + + if (currentUserMessage) { + conversationHistory.push({ + role: "user" as const, + content: currentUserMessage, + }); + } + + return conversationHistory; +}; 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 8305c70945..9cbc2cbcae 100644 --- a/apps/desktop/src/components/right-panel/views/chat-view.tsx +++ b/apps/desktop/src/components/right-panel/views/chat-view.tsx @@ -59,6 +59,7 @@ export function ChatView() { const { isGenerating, + isStreamingText, handleSubmit, handleQuickAction, handleApplyMarkdown, @@ -170,6 +171,8 @@ export function ChatView() { sessionTitle={sessionData.data?.title || "Untitled"} hasEnhancedNote={!!(sessionData.data?.enhancedContent)} onApplyMarkdown={handleApplyMarkdown} + isGenerating={isGenerating} + isStreamingText={isStreamingText} /> )} diff --git a/apps/desktop/src/components/settings/components/ai/llm-local-view.tsx b/apps/desktop/src/components/settings/components/ai/llm-local-view.tsx index e7ff16719e..6469ac7697 100644 --- a/apps/desktop/src/components/settings/components/ai/llm-local-view.tsx +++ b/apps/desktop/src/components/settings/components/ai/llm-local-view.tsx @@ -57,8 +57,8 @@ export function LLMLocalView({ queryClient.invalidateQueries({ queryKey: ["current-llm-model"] }); // Disable BOTH HyprCloud and custom when selecting local - setHyprCloudEnabledMutation.mutate(false); setCustomLLMEnabledMutation.mutate(false); + setHyprCloudEnabledMutation.mutate(false); setOpenAccordion(null); // Restart server for local model diff --git a/apps/desktop/src/components/settings/components/tab-icon.tsx b/apps/desktop/src/components/settings/components/tab-icon.tsx index d2cd8a8e87..6ea708d103 100644 --- a/apps/desktop/src/components/settings/components/tab-icon.tsx +++ b/apps/desktop/src/components/settings/components/tab-icon.tsx @@ -13,6 +13,25 @@ import { import { type Tab } from "./types"; +function McpIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + export function TabIcon({ tab }: { tab: Tab }) { switch (tab) { case "general": @@ -35,6 +54,8 @@ export function TabIcon({ tab }: { tab: Tab }) { return ; case "billing": return ; + case "mcp": + return ; default: return null; } diff --git a/apps/desktop/src/components/settings/components/types.ts b/apps/desktop/src/components/settings/components/types.ts index 175fd6d95b..ac5d0848e6 100644 --- a/apps/desktop/src/components/settings/components/types.ts +++ b/apps/desktop/src/components/settings/components/types.ts @@ -6,6 +6,7 @@ import { CreditCard, LayoutTemplate, MessageSquare, + NetworkIcon, Settings, Sparkles, Volume2, @@ -21,6 +22,7 @@ export type Tab = | "templates" | "feedback" | "integrations" + | "mcp" | "billing"; export const TABS: { name: Tab; icon: LucideIcon }[] = [ @@ -32,6 +34,7 @@ export const TABS: { name: Tab; icon: LucideIcon }[] = [ { name: "sound", icon: Volume2 }, { name: "templates", icon: LayoutTemplate }, { name: "integrations", icon: MessageSquare }, + { name: "mcp", icon: NetworkIcon }, { name: "billing", icon: CreditCard }, { name: "feedback", icon: BlocksIcon }, ]; diff --git a/apps/desktop/src/components/settings/views/index.ts b/apps/desktop/src/components/settings/views/index.ts index 5915287c0c..dfd219708f 100644 --- a/apps/desktop/src/components/settings/views/index.ts +++ b/apps/desktop/src/components/settings/views/index.ts @@ -5,6 +5,7 @@ export { default as Calendar } from "./calendar"; export { default as General } from "./general"; export { default as Integrations } from "./integrations"; export { default as Lab } from "./lab"; +export { default as MCP } from "./mcp"; export { default as Notifications } from "./notifications"; export { default as Profile } from "./profile"; export { default as Sound } from "./sound"; diff --git a/apps/desktop/src/components/settings/views/mcp.tsx b/apps/desktop/src/components/settings/views/mcp.tsx new file mode 100644 index 0000000000..0ae34ef17c --- /dev/null +++ b/apps/desktop/src/components/settings/views/mcp.tsx @@ -0,0 +1,285 @@ +import { commands, type McpServer } from "@hypr/plugin-mcp"; +import { Button } from "@hypr/ui/components/ui/button"; +import { Input } from "@hypr/ui/components/ui/input"; +import { Label } from "@hypr/ui/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; +import { Switch } from "@hypr/ui/components/ui/switch"; +import { useMutation } from "@tanstack/react-query"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +export default function MCP() { + const [servers, setServers] = useState([]); + const [newUrl, setNewUrl] = useState(""); + const [newHeaderKey, setNewHeaderKey] = useState(""); + const [newHeaderValue, setNewHeaderValue] = useState(""); + const [loading, setLoading] = useState(true); + + const MAX_SERVERS = 3; + const isAtMaxLimit = servers.length >= MAX_SERVERS; + + // Load servers on mount + useEffect(() => { + loadServers(); + }, []); + + const loadServers = async () => { + try { + setLoading(true); + const loadedServers = await commands.getServers(); + setServers(loadedServers); + } catch (error) { + console.error("Failed to load MCP servers:", error); + } finally { + setLoading(false); + } + }; + + const saveServersMutation = useMutation({ + mutationFn: async (updatedServers: McpServer[]) => { + await commands.setServers(updatedServers); + return updatedServers; + }, + onSuccess: (updatedServers) => { + setServers(updatedServers); + }, + onError: (error) => { + console.error("Failed to save MCP servers:", error); + }, + }); + + const handleAddServer = () => { + if (!newUrl.trim() || isAtMaxLimit) { + return; + } + + const newServer: McpServer = { + url: newUrl, + type: "sse", + enabled: true, + headerKey: newHeaderKey.trim() || null, + headerValue: newHeaderValue.trim() || null, + }; + + const updatedServers = [...servers, newServer]; + saveServersMutation.mutate(updatedServers); + setNewUrl(""); + setNewHeaderKey(""); + setNewHeaderValue(""); + }; + + const handleToggleServer = (index: number) => { + const updatedServers = servers.map((server, i) => + i === index + ? { ...server, enabled: !server.enabled } + : server + ); + saveServersMutation.mutate(updatedServers); + }; + + const handleDeleteServer = (index: number) => { + const updatedServers = servers.filter((_, i) => i !== index); + saveServersMutation.mutate(updatedServers); + }; + + const handleUpdateServerHeader = (index: number, headerKey: string, headerValue: string) => { + const updatedServers = servers.map((server, i) => + i === index + ? { + ...server, + headerKey: headerKey.trim() || null, + headerValue: headerValue.trim() || null, + } + : server + ); + saveServersMutation.mutate(updatedServers); + }; + + const handleUpdateServerUrl = (index: number, newUrl: string) => { + const updatedServers = servers.map((server, i) => + i === index + ? { ...server, url: newUrl } + : server + ); + saveServersMutation.mutate(updatedServers); + }; + + if (loading) { + return ( +
+
+
+

MCP Servers

+ + Preview + +
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+

MCP Servers

+ + Preview + +
+

+ Connect MCP servers with AI chat (currently supports Claude Sonnet 4, gpt-4o and gpt-4.1) +

+
+ +
+
+

Add New Server

+ +
+
+ + setNewUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isAtMaxLimit) { + handleAddServer(); + } + }} + disabled={isAtMaxLimit} + className="mt-1" + /> +
+ +
+
+ + setNewHeaderKey(e.target.value)} + disabled={isAtMaxLimit} + className="mt-1" + /> +
+
+ + setNewHeaderValue(e.target.value)} + disabled={isAtMaxLimit} + className="mt-1" + /> +
+
+
+ + +
+ + {isAtMaxLimit && ( +
+ Due to stability issues, we only allow {MAX_SERVERS}{" "} + MCP servers during preview. Remove a server to add a new one. +
+ )} + + {servers.length === 0 + ? ( +
+

No MCP servers configured

+

Add a server URL above to get started

+
+ ) + : ( +
+ {servers.map((server, index) => ( +
+
+
+
+ handleUpdateServerUrl(index, e.target.value)} + className="flex-1" + /> + +
+
+ +
+ + handleToggleServer(index)} + /> + +
+
+ + {/* Header configuration for existing servers */} +
+
+ + handleUpdateServerHeader(index, e.target.value, server.headerValue || "")} + className="mt-1" + /> +
+
+ + handleUpdateServerHeader(index, server.headerKey || "", e.target.value)} + className="mt-1" + /> +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 5ced097c29..ed38435eae 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -469,7 +469,7 @@ msgstr "Base URL" msgid "Built-in Templates" msgstr "Built-in Templates" -#: src/routes/app.settings.tsx:58 +#: src/routes/app.settings.tsx:59 msgid "Calendar" msgstr "Calendar" @@ -795,8 +795,8 @@ msgstr "Enter the base URL for your custom LLM endpoint" msgid "Extract action items" msgstr "Extract action items" -#: src/routes/app.settings.tsx:68 -#: src/routes/app.settings.tsx:103 +#: src/routes/app.settings.tsx:69 +#: src/routes/app.settings.tsx:106 msgid "Feedback" msgstr "Feedback" @@ -832,7 +832,7 @@ msgstr "Finish Onboarding" msgid "Full name" msgstr "Full name" -#: src/routes/app.settings.tsx:52 +#: src/routes/app.settings.tsx:53 msgid "General" msgstr "General" @@ -900,12 +900,12 @@ msgstr "Important Q&As" #~ msgid "Integration with other apps like Notion and Google Calendar" #~ msgstr "Integration with other apps like Notion and Google Calendar" -#: src/routes/app.settings.tsx:66 +#: src/routes/app.settings.tsx:67 #: src/components/settings/views/integrations.tsx:124 msgid "Integrations" msgstr "Integrations" -#: src/routes/app.settings.tsx:54 +#: src/routes/app.settings.tsx:55 msgid "Intelligence" msgstr "Intelligence" @@ -965,7 +965,7 @@ msgstr "Learn more about AI autonomy" msgid "Learn more about templates" msgstr "Learn more about templates" -#: src/routes/app.settings.tsx:70 +#: src/routes/app.settings.tsx:71 msgid "License" msgstr "License" @@ -1026,6 +1026,10 @@ msgstr "Loading..." msgid "Make it public" msgstr "Make it public" +#: src/routes/app.settings.tsx:73 +msgid "MCP" +msgstr "MCP" + #: src/components/settings/views/billing.tsx:41 #~ msgid "Meeting note sharing via links" #~ msgstr "Meeting note sharing via links" @@ -1147,7 +1151,7 @@ msgstr "No upcoming events for this organization" msgid "No upcoming events with this contact" msgstr "No upcoming events with this contact" -#: src/routes/app.settings.tsx:60 +#: src/routes/app.settings.tsx:61 msgid "Notifications" msgstr "Notifications" @@ -1424,7 +1428,7 @@ msgstr "Show notifications when you join a meeting." msgid "Some downloads failed, but you can continue" msgstr "Some downloads failed, but you can continue" -#: src/routes/app.settings.tsx:64 +#: src/routes/app.settings.tsx:65 msgid "Sound" msgstr "Sound" @@ -1501,7 +1505,7 @@ msgstr "Teamspace" msgid "Template" msgstr "Template" -#: src/routes/app.settings.tsx:62 +#: src/routes/app.settings.tsx:63 msgid "Templates" msgstr "Templates" @@ -1565,7 +1569,7 @@ msgstr "Toggle transcript panel" #~ msgid "Transcribing" #~ msgstr "Transcribing" -#: src/routes/app.settings.tsx:56 +#: src/routes/app.settings.tsx:57 msgid "Transcription" msgstr "Transcription" diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 331ff92e74..74f6bb66ac 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -469,7 +469,7 @@ msgstr "" msgid "Built-in Templates" msgstr "" -#: src/routes/app.settings.tsx:58 +#: src/routes/app.settings.tsx:59 msgid "Calendar" msgstr "" @@ -795,8 +795,8 @@ msgstr "" msgid "Extract action items" msgstr "" -#: src/routes/app.settings.tsx:68 -#: src/routes/app.settings.tsx:103 +#: src/routes/app.settings.tsx:69 +#: src/routes/app.settings.tsx:106 msgid "Feedback" msgstr "" @@ -832,7 +832,7 @@ msgstr "" msgid "Full name" msgstr "" -#: src/routes/app.settings.tsx:52 +#: src/routes/app.settings.tsx:53 msgid "General" msgstr "" @@ -900,12 +900,12 @@ msgstr "" #~ msgid "Integration with other apps like Notion and Google Calendar" #~ msgstr "" -#: src/routes/app.settings.tsx:66 +#: src/routes/app.settings.tsx:67 #: src/components/settings/views/integrations.tsx:124 msgid "Integrations" msgstr "" -#: src/routes/app.settings.tsx:54 +#: src/routes/app.settings.tsx:55 msgid "Intelligence" msgstr "" @@ -965,7 +965,7 @@ msgstr "" msgid "Learn more about templates" msgstr "" -#: src/routes/app.settings.tsx:70 +#: src/routes/app.settings.tsx:71 msgid "License" msgstr "" @@ -1026,6 +1026,10 @@ msgstr "" msgid "Make it public" msgstr "" +#: src/routes/app.settings.tsx:73 +msgid "MCP" +msgstr "" + #: src/components/settings/views/billing.tsx:41 #~ msgid "Meeting note sharing via links" #~ msgstr "" @@ -1147,7 +1151,7 @@ msgstr "" msgid "No upcoming events with this contact" msgstr "" -#: src/routes/app.settings.tsx:60 +#: src/routes/app.settings.tsx:61 msgid "Notifications" msgstr "" @@ -1424,7 +1428,7 @@ msgstr "" msgid "Some downloads failed, but you can continue" msgstr "" -#: src/routes/app.settings.tsx:64 +#: src/routes/app.settings.tsx:65 msgid "Sound" msgstr "" @@ -1501,7 +1505,7 @@ msgstr "" msgid "Template" msgstr "" -#: src/routes/app.settings.tsx:62 +#: src/routes/app.settings.tsx:63 msgid "Templates" msgstr "" @@ -1565,7 +1569,7 @@ msgstr "" #~ msgid "Transcribing" #~ msgstr "" -#: src/routes/app.settings.tsx:56 +#: src/routes/app.settings.tsx:57 msgid "Transcription" msgstr "" diff --git a/apps/desktop/src/routes/app.settings.tsx b/apps/desktop/src/routes/app.settings.tsx index 4c225fef1a..b4001dbf7d 100644 --- a/apps/desktop/src/routes/app.settings.tsx +++ b/apps/desktop/src/routes/app.settings.tsx @@ -14,6 +14,7 @@ import { Calendar, General, Integrations, + MCP, Notifications, Sound, TemplatesView, @@ -68,6 +69,8 @@ function Component() { return t`Feedback`; case "billing": return t`License`; + case "mcp": + return t`MCP`; default: return tab; } @@ -135,6 +138,7 @@ function Component() { {search.tab === "ai-llm" && } {search.tab === "templates" && } {search.tab === "integrations" && } + {search.tab === "mcp" && } {search.tab === "billing" && }
diff --git a/apps/desktop/src/utils/broadcast.ts b/apps/desktop/src/utils/broadcast.ts index 21c135eed4..69b557c29e 100644 --- a/apps/desktop/src/utils/broadcast.ts +++ b/apps/desktop/src/utils/broadcast.ts @@ -74,6 +74,12 @@ export function broadcastQueryClient(queryClient: QueryClient) { queryKey: ["org", keys[1]], }); } + + if (keys[0] === "mcp-tools") { + queryClient.invalidateQueries({ + queryKey: ["mcp-tools"], + }); + } }); }; diff --git a/crates/db-user/src/chat_messages_migration_1.sql b/crates/db-user/src/chat_messages_migration_1.sql new file mode 100644 index 0000000000..7dfe274d02 --- /dev/null +++ b/crates/db-user/src/chat_messages_migration_1.sql @@ -0,0 +1,4 @@ +ALTER TABLE + chat_messages +ADD + COLUMN type TEXT DEFAULT 'text-delta'; diff --git a/crates/db-user/src/chat_messages_ops.rs b/crates/db-user/src/chat_messages_ops.rs index a82c3ab509..560eda9a68 100644 --- a/crates/db-user/src/chat_messages_ops.rs +++ b/crates/db-user/src/chat_messages_ops.rs @@ -14,8 +14,9 @@ impl UserDatabase { group_id, created_at, role, - content - ) VALUES (?, ?, ?, ?, ?) + content, + type + ) VALUES (?, ?, ?, ?, ?, ?) RETURNING *", vec![ message.id, @@ -23,6 +24,7 @@ impl UserDatabase { message.created_at.to_rfc3339(), message.role.to_string(), message.content, + message.r#type.to_string(), ], ) .await?; diff --git a/crates/db-user/src/chat_messages_types.rs b/crates/db-user/src/chat_messages_types.rs index 68b0177c96..1665fda4ea 100644 --- a/crates/db-user/src/chat_messages_types.rs +++ b/crates/db-user/src/chat_messages_types.rs @@ -8,6 +8,24 @@ user_common_derives! { } } +user_common_derives! { + #[derive(strum::EnumString, strum::Display)] + pub enum ChatMessageType { + #[serde(rename = "text-delta")] + #[strum(serialize = "text-delta")] + TextDelta, + #[serde(rename = "tool-start")] + #[strum(serialize = "tool-start")] + ToolStart, + #[serde(rename = "tool-result")] + #[strum(serialize = "tool-result")] + ToolResult, + #[serde(rename = "tool-error")] + #[strum(serialize = "tool-error")] + ToolError, + } +} + user_common_derives! { pub struct ChatMessage { pub id: String, @@ -15,5 +33,6 @@ user_common_derives! { pub created_at: chrono::DateTime, pub role: ChatMessageRole, pub content: String, + pub r#type: ChatMessageType, } } diff --git a/crates/db-user/src/init.rs b/crates/db-user/src/init.rs index 458b9a82ed..b6d8a1f2a7 100644 --- a/crates/db-user/src/init.rs +++ b/crates/db-user/src/init.rs @@ -1,8 +1,8 @@ use crate::{Config, ConfigAI, ConfigGeneral, ConfigNotification}; use super::{ - Calendar, ChatGroup, ChatMessage, ChatMessageRole, Event, Human, Organization, Platform, - Session, Tag, UserDatabase, + Calendar, ChatGroup, ChatMessage, ChatMessageRole, ChatMessageType, Event, Human, Organization, + Platform, Session, Tag, UserDatabase, }; const ONBOARDING_RAW_HTML: &str = include_str!("../assets/onboarding-raw.html"); @@ -794,6 +794,7 @@ pub async fn seed(db: &UserDatabase, user_id: impl Into) -> Result<(), c role: ChatMessageRole::User, content: "Hello, how are you?".to_string(), created_at: now, + r#type: ChatMessageType::TextDelta, }; let _ = db.upsert_chat_message(chat_message_1).await?; diff --git a/crates/db-user/src/lib.rs b/crates/db-user/src/lib.rs index 55507b9edb..abb73c2056 100644 --- a/crates/db-user/src/lib.rs +++ b/crates/db-user/src/lib.rs @@ -129,7 +129,7 @@ impl std::ops::Deref for UserDatabase { } // Append only. Do not reorder. -const MIGRATIONS: [&str; 22] = [ +const MIGRATIONS: [&str; 23] = [ include_str!("./calendars_migration.sql"), include_str!("./configs_migration.sql"), include_str!("./events_migration.sql"), @@ -152,6 +152,7 @@ const MIGRATIONS: [&str; 22] = [ include_str!("./events_migration_1.sql"), include_str!("./session_participants_migration_1.sql"), include_str!("./events_migration_2.sql"), + include_str!("./chat_messages_migration_1.sql"), ]; pub async fn migrate(db: &UserDatabase) -> Result<(), crate::Error> { diff --git a/crates/template/assets/ai_chat_system.jinja b/crates/template/assets/ai_chat_system.jinja index db68957d67..e865274836 100644 --- a/crates/template/assets/ai_chat_system.jinja +++ b/crates/template/assets/ai_chat_system.jinja @@ -144,10 +144,17 @@ Your response would mostly be either of the two formats: " -{% if modelId == "gpt-4.1" or modelId == "openai/gpt-4.1" or modelId == "anthropic/claude-4-sonnet" or modelId == "openai/gpt-4o" or modelId == "gpt-4o"%} +{% if modelId == "gpt-4.1" or modelId == "openai/gpt-4.1" or modelId == "anthropic/claude-sonnet-4" or modelId == "openai/gpt-4o" or modelId == "gpt-4o" or (apiBase and "pro.hyprnote.com" in apiBase)%} [Tool Calling] Here are available tools that you can call to get more information. -{important} not all models have access to all tools. If you can't call a tool, tell user that your model doesn't have access, so try switching to a different model like gpt-4.1 or claude 4 sonnet. + +- custom MCP tools: user will be able to request you to call MCP tools. Often times, they are related to other producitvity tools like Slack, Notion, Google workspace, etc. + Below is the list of tools that you have access to. + +{% for tool in mcpTools %} + +- {{ tool.name }}: {{ tool.description }}. Input schema is {{ tool.inputSchema }}. + {% endfor %} - search_sessions_multi_keywords: Search for sessions (meeting notes) with multiple keywords. The keywords should be the most important things that the user is talking about. This could be either topics, people, or company names. when you return the response for a request that used search_sessions_multi_keywords, you should smartly combined the information from the tool call results, instead of naively returning all raw results (oftentime thses could be very long). diff --git a/crates/template/assets/enhance.system.jinja b/crates/template/assets/enhance.system.jinja index 4fa1aa3698..add87dab0c 100644 --- a/crates/template/assets/enhance.system.jinja +++ b/crates/template/assets/enhance.system.jinja @@ -35,6 +35,7 @@ The sections and their order in your output must exactly match those defined in Sections in the template should be the only existing sections in the enhanced note, as markdown 1 headers h1 (#). Each section's content must strictly adhere to the section description provided by the user. Extract and organize relevant information from the raw notes and transcript to fulfill each section's requirements. CRITICAL: Only include information that exists in the source material. If the transcript or raw notes do not contain information relevant to a specific section, explicitly say so. Do not generate, infer, or create content that is not directly supported by the source material. +CRITICAL: Do not include any of your thought process or comments. Just return the end result of the enhanced note, do not explain or justify your results (regardless of whether template is given or not) ---Template Structure--- {{ templateInfo }} @@ -81,7 +82,7 @@ You will be given multiple inputs from the user. Below are useful information th - Disclaimer: Raw notes and the transcript may contain errors made by human and STT, respectively. So it is important you make the best out of every material to create the best enhanced meeting note. - Do not include meeting note title, attendee lists nor explanatory notes about the output structure. Just print a markdown document. -- Go straight to the actual note part, there is no need to include any of your thought process or comments. Just return the meeting note. +- Go straight to the actual note part, never include any of your thought process or comments. Just return the meeting note, do not explain or justify your results (regardless of whether template is given or not) - It is super important to acknowledge what the user found to be important, and raw notes show a glimpse of the important information as well as moments during the meeting. Naturally integrate raw note entries into relevant sections instead of forcefully converting them into headers. - Preserve essential details; avoid excessive abstraction. Ensure content remains concrete and specific. - Pay close attention to emphasized text in raw notes. Users highlight information using four styles: bold(**text**), italic(_text_), underline(text), strikethrough(~~text~~). diff --git a/packages/utils/src/ai.ts b/packages/utils/src/ai.ts index dd1e854de4..85ecdfd0b9 100644 --- a/packages/utils/src/ai.ts +++ b/packages/utils/src/ai.ts @@ -5,7 +5,17 @@ import { getLicenseKey } from "tauri-plugin-keygen-api"; import { commands as connectorCommands } from "@hypr/plugin-connector"; import { fetch as customFetch } from "@hypr/utils"; -export { generateObject, generateText, type Provider, smoothStream, stepCountIs, streamText, tool } from "ai"; +export { + dynamicTool, + experimental_createMCPClient, + generateObject, + generateText, + type Provider, + smoothStream, + stepCountIs, + streamText, + tool, +} from "ai"; export const localProviderName = "hypr-llm-local"; export const remoteProviderName = "hypr-llm-remote"; diff --git a/plugins/db/js/bindings.gen.ts b/plugins/db/js/bindings.gen.ts index 0dc0e540c0..e38f4ee566 100644 --- a/plugins/db/js/bindings.gen.ts +++ b/plugins/db/js/bindings.gen.ts @@ -162,8 +162,9 @@ async deleteTag(tagId: string) : Promise { export type Calendar = { id: string; tracking_id: string; user_id: string; platform: Platform; name: string; selected: boolean; source: string | null } 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 } +export type ChatMessage = { id: string; group_id: string; created_at: string; role: ChatMessageRole; content: string; type: ChatMessageType } export type ChatMessageRole = "User" | "Assistant" +export type ChatMessageType = "text-delta" | "tool-start" | "tool-result" | "tool-error" 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 }