diff --git a/frontend/components/code-component.tsx b/frontend/components/code-component.tsx index e612484d..b16fbe18 100644 --- a/frontend/components/code-component.tsx +++ b/frontend/components/code-component.tsx @@ -1,7 +1,6 @@ -import { Check, Copy } from "lucide-react"; -import { useState } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { tomorrow } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; type CodeComponentProps = { @@ -9,8 +8,23 @@ type CodeComponentProps = { language: string; }; -export default function CodeComponent({ code, language }: CodeComponentProps) { +const CodeComponent = memo(function CodeComponent({ + code, + language, +}: CodeComponentProps) { const [isCopied, setIsCopied] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const { lineCount, shouldCollapse, previewCode } = useMemo(() => { + const lines = code.split("\n"); + const lineCount = lines.length; + const shouldCollapse = lineCount > 10; + const previewCode = shouldCollapse + ? lines.slice(0, 8).join("\n") + "\n..." + : code; + + return { lineCount, shouldCollapse, previewCode }; + }, [code]); const copyToClipboard = () => { if (!navigator.clipboard || !navigator.clipboard.writeText) { @@ -26,31 +40,111 @@ export default function CodeComponent({ code, language }: CodeComponentProps) { }); }; + const displayCode = shouldCollapse && !isExpanded ? previewCode : code; + const maxHeight = isExpanded ? "600px" : shouldCollapse ? "200px" : "400px"; + return (
- + )} + + +
+ + + {/* Code content */} +
+
+
+ {displayCode} +
+
+ + {/* Fade overlay for collapsed state */} + {shouldCollapse && !isExpanded && ( +
)} - - - {code} - +
); -} +}); + +export default CodeComponent; diff --git a/frontend/components/markdown-renderer.tsx b/frontend/components/markdown-renderer.tsx index d6c8c81a..a9a710ad 100644 --- a/frontend/components/markdown-renderer.tsx +++ b/frontend/components/markdown-renderer.tsx @@ -1,8 +1,9 @@ +import { cn } from "@/lib/utils"; +import { useMemo } from "react"; import Markdown from "react-markdown"; import rehypeMathjax from "rehype-mathjax"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; -import { cn } from "@/lib/utils"; import CodeComponent from "./code-component"; type MarkdownRendererProps = { @@ -48,92 +49,176 @@ export const cleanupTableEmptyCells = (text: string): string => { }) .join("\n"); }; + export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => { // Process the chat message to handle tags and clean up tables const processedChatMessage = preprocessChatMessage(chatMessage); + // Memoize the components object to prevent CodeComponent recreation + const markdownComponents = useMemo( + () => ({ + p({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +

{props.children}

+ ); + }, + ol({ node, ...props }: { node?: any; [key: string]: any }) { + return
    {props.children}
; + }, + ul({ node, ...props }: { node?: any; [key: string]: any }) { + return
    {props.children}
; + }, + li({ node, ...props }: { node?: any; [key: string]: any }) { + return
  • {props.children}
  • ; + }, + h1({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +

    + {props.children} +

    + ); + }, + h2({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +

    + {props.children} +

    + ); + }, + h3({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +

    + {props.children} +

    + ); + }, + h4({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +

    + {props.children} +

    + ); + }, + hr({ node, ...props }: { node?: any; [key: string]: any }) { + return
    ; + }, + blockquote({ node, ...props }: { node?: any; [key: string]: any }) { + return ( +
    + {props.children} +
    + ); + }, + pre({ node, ...props }: { node?: any; [key: string]: any }) { + return <>{props.children}; + }, + table: ({ node, ...props }: { node?: any; [key: string]: any }) => { + return ( +
    +
    + {props.children}
    +
    +
    + ); + }, + thead: ({ node, ...props }: { node?: any; [key: string]: any }) => { + return ( + + {props.children} + + ); + }, + tbody: ({ node, ...props }: { node?: any; [key: string]: any }) => { + return ( + {props.children} + ); + }, + th: ({ node, ...props }: { node?: any; [key: string]: any }) => { + return ( + + {props.children} + + ); + }, + td: ({ node, ...props }: { node?: any; [key: string]: any }) => { + return ( + {props.children} + ); + }, + code: ({ + node, + className, + inline, + children, + ...props + }: { + node?: any; + [key: string]: any; + }) => { + let content = children as string; + if ( + Array.isArray(children) && + children.length === 1 && + typeof children[0] === "string" + ) { + content = children[0] as string; + } + if (typeof content === "string") { + if (content.length) { + if (content[0] === "▍") { + return ; + } + + // Specifically handle tags that were wrapped in backticks + if (content === "" || content === "") { + return ( + + {content} + + ); + } + } + + const match = /language-(\w+)/.exec(className || ""); + + return !inline ? ( + + ) : ( + + {content} + + ); + } + }, + }), + [] + ); + return (
    {props.children}

    ; - }, - ol({ node, ...props }) { - return
      {props.children}
    ; - }, - h1({ node, ...props }) { - return

    {props.children}

    ; - }, - h2({ node, ...props }) { - return

    {props.children}

    ; - }, - h3({ node, ...props }) { - return

    {props.children}

    ; - }, - hr({ node, ...props }) { - return
    ; - }, - ul({ node, ...props }) { - return
      {props.children}
    ; - }, - pre({ node, ...props }) { - return <>{props.children}; - }, - table: ({ node, ...props }) => { - return ( -
    -
    - {props.children}
    -
    -
    - ); - }, - - code: ({ node, className, inline, children, ...props }) => { - let content = children as string; - if ( - Array.isArray(children) && - children.length === 1 && - typeof children[0] === "string" - ) { - content = children[0] as string; - } - if (typeof content === "string") { - if (content.length) { - if (content[0] === "▍") { - return ; - } - - // Specifically handle tags that were wrapped in backticks - if (content === "" || content === "") { - return {content}; - } - } - - const match = /language-(\w+)/.exec(className || ""); - - return !inline ? ( - - ) : ( - - {content} - - ); - } - }, - }} + components={markdownComponents} > {processedChatMessage}
    diff --git a/frontend/src/app/chat/page.tsx b/frontend/src/app/chat/page.tsx index 072567ba..38e8b2c4 100644 --- a/frontend/src/app/chat/page.tsx +++ b/frontend/src/app/chat/page.tsx @@ -1,5 +1,14 @@ "use client"; +import { MarkdownRenderer } from "@/components/markdown-renderer"; +import { ProtectedRoute } from "@/components/protected-route"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth-context"; +import { type EndpointType, useChat } from "@/contexts/chat-context"; +import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; +import { useTask } from "@/contexts/task-context"; +import { useLoadingStore } from "@/stores/loadingStore"; import { AtSign, Bot, @@ -15,15 +24,6 @@ import { Zap, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { MarkdownRenderer } from "@/components/markdown-renderer"; -import { ProtectedRoute } from "@/components/protected-route"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { useAuth } from "@/contexts/auth-context"; -import { type EndpointType, useChat } from "@/contexts/chat-context"; -import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context"; -import { useTask } from "@/contexts/task-context"; -import { useLoadingStore } from "@/stores/loadingStore"; import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery"; import Nudges from "./nudges"; @@ -194,7 +194,7 @@ function ChatPage() { "Upload failed with status:", response.status, "Response:", - errorText, + errorText ); throw new Error("Failed to process document"); } @@ -448,7 +448,7 @@ function ChatPage() { console.log( "Loading conversation with", conversationData.messages.length, - "messages", + "messages" ); // Convert backend message format to frontend Message interface const convertedMessages: Message[] = conversationData.messages.map( @@ -576,7 +576,7 @@ function ChatPage() { ) === "string" ? toolCall.function?.arguments || toolCall.arguments : JSON.stringify( - toolCall.function?.arguments || toolCall.arguments, + toolCall.function?.arguments || toolCall.arguments ), result: toolCall.result, status: "completed", @@ -595,7 +595,7 @@ function ChatPage() { } return message; - }, + } ); setMessages(convertedMessages); @@ -684,7 +684,7 @@ function ChatPage() { console.log( "Chat page received file upload error event:", filename, - error, + error ); // Replace the last message with error message @@ -698,37 +698,37 @@ function ChatPage() { window.addEventListener( "fileUploadStart", - handleFileUploadStart as EventListener, + handleFileUploadStart as EventListener ); window.addEventListener( "fileUploaded", - handleFileUploaded as EventListener, + handleFileUploaded as EventListener ); window.addEventListener( "fileUploadComplete", - handleFileUploadComplete as EventListener, + handleFileUploadComplete as EventListener ); window.addEventListener( "fileUploadError", - handleFileUploadError as EventListener, + handleFileUploadError as EventListener ); return () => { window.removeEventListener( "fileUploadStart", - handleFileUploadStart as EventListener, + handleFileUploadStart as EventListener ); window.removeEventListener( "fileUploaded", - handleFileUploaded as EventListener, + handleFileUploaded as EventListener ); window.removeEventListener( "fileUploadComplete", - handleFileUploadComplete as EventListener, + handleFileUploadComplete as EventListener ); window.removeEventListener( "fileUploadError", - handleFileUploadError as EventListener, + handleFileUploadError as EventListener ); }; }, [endpoint, setPreviousResponseIds]); @@ -755,7 +755,7 @@ function ChatPage() { }, [isFilterDropdownOpen]); const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery( - previousResponseIds[endpoint], + previousResponseIds[endpoint] ); const handleSSEStream = async (userMessage: Message) => { @@ -860,7 +860,7 @@ function ChatPage() { console.log( "Received chunk:", chunk.type || chunk.object, - chunk, + chunk ); // Extract response ID if present @@ -876,14 +876,14 @@ function ChatPage() { if (chunk.delta.function_call) { console.log( "Function call in delta:", - chunk.delta.function_call, + chunk.delta.function_call ); // Check if this is a new function call if (chunk.delta.function_call.name) { console.log( "New function call:", - chunk.delta.function_call.name, + chunk.delta.function_call.name ); const functionCall: FunctionCall = { name: chunk.delta.function_call.name, @@ -899,7 +899,7 @@ function ChatPage() { else if (chunk.delta.function_call.arguments) { console.log( "Function call arguments delta:", - chunk.delta.function_call.arguments, + chunk.delta.function_call.arguments ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; @@ -911,14 +911,14 @@ function ChatPage() { chunk.delta.function_call.arguments; console.log( "Accumulated arguments:", - lastFunctionCall.argumentsString, + lastFunctionCall.argumentsString ); // Try to parse arguments if they look complete if (lastFunctionCall.argumentsString.includes("}")) { try { const parsed = JSON.parse( - lastFunctionCall.argumentsString, + lastFunctionCall.argumentsString ); lastFunctionCall.arguments = parsed; lastFunctionCall.status = "completed"; @@ -926,7 +926,7 @@ function ChatPage() { } catch (e) { console.log( "Arguments not yet complete or invalid JSON:", - e, + e ); } } @@ -959,7 +959,7 @@ function ChatPage() { else if (toolCall.function.arguments) { console.log( "Tool call arguments delta:", - toolCall.function.arguments, + toolCall.function.arguments ); const lastFunctionCall = currentFunctionCalls[ @@ -973,7 +973,7 @@ function ChatPage() { toolCall.function.arguments; console.log( "Accumulated tool arguments:", - lastFunctionCall.argumentsString, + lastFunctionCall.argumentsString ); // Try to parse arguments if they look complete @@ -982,7 +982,7 @@ function ChatPage() { ) { try { const parsed = JSON.parse( - lastFunctionCall.argumentsString, + lastFunctionCall.argumentsString ); lastFunctionCall.arguments = parsed; lastFunctionCall.status = "completed"; @@ -990,7 +990,7 @@ function ChatPage() { } catch (e) { console.log( "Tool arguments not yet complete or invalid JSON:", - e, + e ); } } @@ -1022,7 +1022,7 @@ function ChatPage() { console.log( "Error parsing function call on finish:", fc, - e, + e ); } } @@ -1038,12 +1038,12 @@ function ChatPage() { console.log( "🟢 CREATING function call (added):", chunk.item.id, - chunk.item.tool_name || chunk.item.name, + chunk.item.tool_name || chunk.item.name ); // Try to find an existing pending call to update (created by earlier deltas) let existing = currentFunctionCalls.find( - (fc) => fc.id === chunk.item.id, + (fc) => fc.id === chunk.item.id ); if (!existing) { existing = [...currentFunctionCalls] @@ -1052,7 +1052,7 @@ function ChatPage() { (fc) => fc.status === "pending" && !fc.id && - fc.name === (chunk.item.tool_name || chunk.item.name), + fc.name === (chunk.item.tool_name || chunk.item.name) ); } @@ -1065,7 +1065,7 @@ function ChatPage() { chunk.item.inputs || existing.arguments; console.log( "🟢 UPDATED existing pending function call with id:", - existing.id, + existing.id ); } else { const functionCall: FunctionCall = { @@ -1083,7 +1083,7 @@ function ChatPage() { currentFunctionCalls.map((fc) => ({ id: fc.id, name: fc.name, - })), + })) ); } } @@ -1094,7 +1094,7 @@ function ChatPage() { ) { console.log( "Function args delta (Realtime API):", - chunk.delta, + chunk.delta ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; @@ -1105,7 +1105,7 @@ function ChatPage() { lastFunctionCall.argumentsString += chunk.delta || ""; console.log( "Accumulated arguments (Realtime API):", - lastFunctionCall.argumentsString, + lastFunctionCall.argumentsString ); } } @@ -1116,26 +1116,26 @@ function ChatPage() { ) { console.log( "Function args done (Realtime API):", - chunk.arguments, + chunk.arguments ); const lastFunctionCall = currentFunctionCalls[currentFunctionCalls.length - 1]; if (lastFunctionCall) { try { lastFunctionCall.arguments = JSON.parse( - chunk.arguments || "{}", + chunk.arguments || "{}" ); lastFunctionCall.status = "completed"; console.log( "Parsed function arguments (Realtime API):", - lastFunctionCall.arguments, + lastFunctionCall.arguments ); } catch (e) { lastFunctionCall.arguments = { raw: chunk.arguments }; lastFunctionCall.status = "error"; console.log( "Error parsing function arguments (Realtime API):", - e, + e ); } } @@ -1149,14 +1149,14 @@ function ChatPage() { console.log( "🔵 UPDATING function call (done):", chunk.item.id, - chunk.item.tool_name || chunk.item.name, + chunk.item.tool_name || chunk.item.name ); console.log( "🔵 Looking for existing function calls:", currentFunctionCalls.map((fc) => ({ id: fc.id, name: fc.name, - })), + })) ); // Find existing function call by ID or name @@ -1164,14 +1164,14 @@ function ChatPage() { (fc) => fc.id === chunk.item.id || fc.name === chunk.item.tool_name || - fc.name === chunk.item.name, + fc.name === chunk.item.name ); if (functionCall) { console.log( "🔵 FOUND existing function call, updating:", functionCall.id, - functionCall.name, + functionCall.name ); // Update existing function call with completion data functionCall.status = @@ -1194,7 +1194,7 @@ function ChatPage() { "🔴 WARNING: Could not find existing function call to update:", chunk.item.id, chunk.item.tool_name, - chunk.item.name, + chunk.item.name ); } } @@ -1215,7 +1215,7 @@ function ChatPage() { fc.name === chunk.item.name || fc.name === chunk.item.type || fc.name.includes(chunk.item.type.replace("_call", "")) || - chunk.item.type.includes(fc.name), + chunk.item.type.includes(fc.name) ); if (functionCall) { @@ -1259,12 +1259,12 @@ function ChatPage() { "🟡 CREATING tool call (added):", chunk.item.id, chunk.item.tool_name || chunk.item.name, - chunk.item.type, + chunk.item.type ); // Dedupe by id or pending with same name let existing = currentFunctionCalls.find( - (fc) => fc.id === chunk.item.id, + (fc) => fc.id === chunk.item.id ); if (!existing) { existing = [...currentFunctionCalls] @@ -1276,7 +1276,7 @@ function ChatPage() { fc.name === (chunk.item.tool_name || chunk.item.name || - chunk.item.type), + chunk.item.type) ); } @@ -1292,7 +1292,7 @@ function ChatPage() { chunk.item.inputs || existing.arguments; console.log( "🟡 UPDATED existing pending tool call with id:", - existing.id, + existing.id ); } else { const functionCall = { @@ -1313,7 +1313,7 @@ function ChatPage() { id: fc.id, name: fc.name, type: fc.type, - })), + })) ); } } @@ -1591,7 +1591,7 @@ function ChatPage() { const handleForkConversation = ( messageIndex: number, - event?: React.MouseEvent, + event?: React.MouseEvent ) => { // Prevent any default behavior and stop event propagation if (event) { @@ -1656,7 +1656,7 @@ function ChatPage() { const renderFunctionCalls = ( functionCalls: FunctionCall[], - messageIndex?: number, + messageIndex?: number ) => { if (!functionCalls || functionCalls.length === 0) return null; @@ -1734,7 +1734,7 @@ function ChatPage() { {(fc.arguments || fc.argumentsString) && (
    Arguments: -
    +                      
                             {fc.arguments
                               ? JSON.stringify(fc.arguments, null, 2)
                               : fc.argumentsString || "..."}
    @@ -1870,7 +1870,7 @@ function ChatPage() {
                               
    ) : ( -
    +                        
                               {JSON.stringify(fc.result, null, 2)}
                             
    )} @@ -2024,7 +2024,7 @@ function ChatPage() {
    {renderFunctionCalls( message.functionCalls || [], - index, + index )}
    @@ -2053,7 +2053,7 @@ function ChatPage() {
    {renderFunctionCalls( streamingMessage.functionCalls, - messages.length, + messages.length )} filter.name .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), + .includes(filterSearchTerm.toLowerCase()) ); if (e.key === "Escape") { @@ -2214,7 +2214,7 @@ function ChatPage() { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedFilterIndex((prev) => - prev < filteredFilters.length - 1 ? prev + 1 : 0, + prev < filteredFilters.length - 1 ? prev + 1 : 0 ); return; } @@ -2222,7 +2222,7 @@ function ChatPage() { if (e.key === "ArrowUp") { e.preventDefault(); setSelectedFilterIndex((prev) => - prev > 0 ? prev - 1 : filteredFilters.length - 1, + prev > 0 ? prev - 1 : filteredFilters.length - 1 ); return; } @@ -2240,7 +2240,7 @@ function ChatPage() { ) { e.preventDefault(); handleFilterSelect( - filteredFilters[selectedFilterIndex], + filteredFilters[selectedFilterIndex] ); return; } @@ -2259,7 +2259,7 @@ function ChatPage() { ) { e.preventDefault(); handleFilterSelect( - filteredFilters[selectedFilterIndex], + filteredFilters[selectedFilterIndex] ); return; } @@ -2339,7 +2339,7 @@ function ChatPage() { .filter((filter) => filter.name .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), + .includes(filterSearchTerm.toLowerCase()) ) .map((filter, index) => (