diff --git a/frontend/src/components/thread/content/ThreadContent.tsx b/frontend/src/components/thread/content/ThreadContent.tsx index 25a956e2..5baaa25e 100644 --- a/frontend/src/components/thread/content/ThreadContent.tsx +++ b/frontend/src/components/thread/content/ThreadContent.tsx @@ -18,7 +18,44 @@ import { KortixLogo } from '@/components/sidebar/kortix-logo'; import { AgentLoader } from './loader'; import { parseXmlToolCalls, isNewXmlFormat, extractToolNameFromStream } from '@/components/thread/tool-views/xml-parser'; import { parseToolResult } from '@/components/thread/tool-views/tool-result-parser'; -import Feedback from '@/components/thread/feedback-modal'; +import MessageActions from '@/components/thread/message-actions'; + +// Utility function to extract clean markdown content with formatting preserved +function extractCleanMarkdownContent(content: string): string { + if (!content) return ''; + + let processedContent = content; + + // Extract text from new format ... + const newFormatAskRegex = /[\s\S]*?[\s\S]*?([\s\S]*?)<\/parameter>[\s\S]*?<\/invoke>[\s\S]*?<\/function_calls>/gi; + const newFormatMatches = [...processedContent.matchAll(newFormatAskRegex)]; + + for (const match of newFormatMatches) { + const askText = match[1].trim(); + processedContent = processedContent.replace(match[0], askText); + } + + // Extract text from old format ... + const oldFormatAskRegex = /]*>([\s\S]*?)<\/ask>/gi; + const oldFormatMatches = [...processedContent.matchAll(oldFormatAskRegex)]; + + for (const match of oldFormatMatches) { + const askText = match[1].trim(); + processedContent = processedContent.replace(match[0], askText); + } + + // Remove remaining blocks + processedContent = processedContent.replace(/[\s\S]*?<\/function_calls>/gi, ''); + + // Remove other individual XML tool call tags (but not ask, since we handled those) + processedContent = processedContent.replace(/<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?>(?:[\s\S]*?)<\/\1>|<(?!inform\b)([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/g, ''); + + // Clean up extra whitespace and newlines while preserving markdown structure + return processedContent + .replace(/\n\s*\n\s*\n/g, '\n\n') // Replace multiple newlines with double newlines + .replace(/^\s+|\s+$/g, '') // Trim leading/trailing whitespace + .trim(); +} // Define the set of tags whose raw XML should be hidden during streaming const HIDE_STREAMING_XML_TAGS = new Set([ @@ -410,6 +447,7 @@ export const ThreadContent: React.FC = ({ type: 'user' | 'assistant_group'; messages: UnifiedMessage[]; key: string; + processedContent?: string; }; const groupedMessages: MessageGroup[] = []; let currentGroup: MessageGroup | null = null; @@ -685,6 +723,9 @@ export const ThreadContent: React.FC = ({ const elements: React.ReactNode[] = []; let assistantMessageCount = 0; // Move this outside the loop + // Collect processed content for the Feedback component + const processedContentParts: string[] = []; + group.messages.forEach((message, msgIndex) => { if (message.type === 'assistant') { const parsedContent = safeJsonParse(message.content, {}); @@ -692,6 +733,12 @@ export const ThreadContent: React.FC = ({ if (!parsedContent.content) return; + // Extract clean content for copying + const cleanContent = extractCleanMarkdownContent(parsedContent.content); + if (cleanContent) { + processedContentParts.push(cleanContent); + } + const renderedContent = renderMarkdownContent( parsedContent.content, handleToolClick, @@ -714,6 +761,10 @@ export const ThreadContent: React.FC = ({ } }); + // Store the processed content for the Feedback component + const processedContent = processedContentParts.join('\n\n'); + group.processedContent = processedContent; + return elements; })()} @@ -900,11 +951,25 @@ export const ThreadContent: React.FC = ({ const messageId = firstAssistant?.message_id; if (!messageId) return null; - + // Check if this group is currently streaming + const isCurrentlyStreaming = (() => { + const isLastGroup = groupIndex === finalGroupedMessages.length - 1; + const hasStreamingContent = streamingTextContent || streamingToolCall; + return isLastGroup && hasStreamingContent; + })(); + // Don't show actions for streaming messages + if (isCurrentlyStreaming) return null; + + // Get the processed content that was stored during rendering + const processedContent = group.processedContent; return ( - + ); })()} diff --git a/frontend/src/components/thread/feedback-modal.tsx b/frontend/src/components/thread/message-actions.tsx similarity index 62% rename from frontend/src/components/thread/feedback-modal.tsx rename to frontend/src/components/thread/message-actions.tsx index 20dd0af7..4b8328fc 100644 --- a/frontend/src/components/thread/feedback-modal.tsx +++ b/frontend/src/components/thread/message-actions.tsx @@ -1,17 +1,18 @@ -import { Dialog, DialogTitle, DialogHeader, DialogContent, DialogTrigger, DialogFooter, DialogClose } from "@/components/ui/dialog"; +import { Dialog, DialogTitle, DialogHeader, DialogContent, DialogFooter, DialogClose } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { ThumbsDown, ThumbsUp } from "lucide-react"; -import { useState } from "react"; +import { ThumbsDown, ThumbsUp, Copy } from "lucide-react"; +import { memo, useState } from "react"; import { Textarea } from "../ui/textarea"; import { toast } from "sonner"; import { backendApi } from '@/lib/api-client'; -interface FeedbackProps { +interface MessageActionsProps { messageId: string; initialFeedback?: boolean | null; + processedContent?: string; } -export default function Feedback({ messageId, initialFeedback = null }: FeedbackProps) { +export default memo(function MessageActions({ messageId, initialFeedback = null, processedContent }: MessageActionsProps) { const [open, setOpen] = useState(false); const [submittedFeedback, setSubmittedFeedback] = useState(initialFeedback); const [feedback, setFeedback] = useState(''); @@ -23,6 +24,20 @@ export default function Feedback({ messageId, initialFeedback = null }: Feedback setOpen(true); }; + const handleCopy = async () => { + try { + if (processedContent) { + await navigator.clipboard.writeText(processedContent); + toast.success('Response copied to clipboard'); + } else { + toast.error('No content to copy'); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + toast.error('Failed to copy to clipboard'); + } + }; + const handleSubmit = async () => { if (currentSelection === null) return; @@ -53,26 +68,45 @@ export default function Feedback({ messageId, initialFeedback = null }: Feedback const handleOpenChange = (newOpen: boolean) => { setOpen(newOpen); if (!newOpen) { - // Reset form state when closing without submitting setFeedback(''); setCurrentSelection(null); } }; return ( - + handleClick(true)} + className="h-4 w-4 p-0 rounded-sm hover:bg-muted/50 transition-colors flex items-center justify-center" + onClick={handleCopy} + title="Copy response" > - + + Copy response + + + handleClick(true)} + title="Good response" + > + Good response + handleClick(false)} + title="Bad response" > - + Bad response @@ -109,4 +143,4 @@ export default function Feedback({ messageId, initialFeedback = null }: Feedback ); -} \ No newline at end of file +}); \ No newline at end of file