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