Soham Waghmare
feat: agent mode
7d94a77
import { Avatar } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Message as MessageType, ResearchTree } from "@/lib/types";
import { Bot, Copy, MoreHorizontal, User2, ExternalLink, Loader2, SearchIcon, Terminal, ChevronDown, ChevronRight } from "lucide-react";
import React, { useState, useEffect, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
// Component to display tool calls nicely
const ToolCallDisplay = ({ toolCall }: { toolCall: any }) => {
const getDisplayArgs = () => {
if (!toolCall.args) return "";
if (typeof toolCall.args === "string") return toolCall.args;
if (toolCall.args.query) return toolCall.args.query;
if (toolCall.args.url) return toolCall.args.url;
return JSON.stringify(toolCall.args);
};
const args = getDisplayArgs();
return (
<div className="my-1.5 border rounded-md bg-background/50 overflow-hidden group hover:bg-muted/30 transition-colors cursor-pointer">
<div className="flex items-start px-3 py-2 gap-2 text-xs font-mono text-muted-foreground">
<Terminal className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span className="font-semibold flex-shrink-0">{toolCall.name}</span>
<span className="flex-1 truncate group-hover:whitespace-pre-wrap group-hover:break-words transition-all">
{args}
</span>
</div>
</div>
);
};
// Function to extract all source URLs from the research tree
const extractAllSources = (tree: ResearchTree | undefined): Array<{ text: string; url: string }> => {
if (!tree) return [];
// Start with an empty set to avoid duplicates
const uniqueSources = new Set<string>();
// Recursive function to gather all sources
const collectSources = (node: ResearchTree) => {
// Add all sources from the current node
if (node.sources && Array.isArray(node.sources)) {
node.sources.forEach((url) => uniqueSources.add(url));
}
// Process all children recursively
if (node.children && Array.isArray(node.children)) {
node.children.forEach((child) => collectSources(child));
}
};
// Start the collection process
collectSources(tree);
// Convert the set to an array of objects with text and url properties
return Array.from(uniqueSources).map((url) => {
// Try to extract a readable title from the URL
let text = "";
try {
const urlObj = new URL(url);
// Remove 'www.' if present and take the hostname
text = urlObj.hostname.replace(/^www\./, "");
// Add the pathname if it's not just "/"
if (urlObj.pathname && urlObj.pathname !== "/") {
// Format the pathname - keep it short and clean
const path = urlObj.pathname.split("/").filter(Boolean);
if (path.length > 0) {
const lastPathSegment = path[path.length - 1]
.replace(/[-_]/g, " ") // Replace dashes and underscores with spaces
.replace(/\.html$|\.pdf$|\.php$/, ""); // Remove common extensions
text = `${text} - ${lastPathSegment}`;
}
}
} catch (e) {
// If URL parsing fails, use the URL as is
text = url;
}
return { text, url };
});
};
// SourceLinks component for displaying research sources in a scrollable container
const SourceLinks = ({ links }: { links: Array<{ text: string; url: string }> }) => {
if (!links || links.length === 0) return null;
return (
<div className="mt-4 mb-4">
<h3 className="text-md font-semibold mb-2">Research Sources:</h3>
<ScrollArea className="w-full h-[300px] overflow-auto border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm p-1" type="always">
<div className="space-y-2">
{links.map((link, index) => {
// Extract domain for display
let domain = "";
try {
const urlObj = new URL(link.url);
domain = urlObj.hostname.replace(/^www\./, "");
} catch (e) {
domain = "Unknown source";
}
return (
<div key={index} className="flex items-start gap-2 group hover:bg-muted/50 p-2 rounded-md transition-colors">
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
<div className="flex-1">
<a href={link.url} className="text-primary hover:underline text-sm block" target="_blank" rel="noopener noreferrer">
{domain}
</a>
<a href={link.url} className="text-xs text-muted-foreground hover:underline block truncate" target="_blank" rel="noopener noreferrer" title={link.url}>
{link.url}
</a>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(link.url);
}}>
<Copy className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy link</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
};
// ImageGallery component for handling images in a scrollable container
const ImageGallery = ({ imageUrls }: { imageUrls: string[] }) => {
const [loadedImages, setLoadedImages] = useState<string[]>([]);
// Lazy load images using Intersection Observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.getAttribute("data-src");
if (src) {
img.src = src;
observer.unobserve(img);
setLoadedImages((prev) => [...prev, src]);
}
}
});
},
{ rootMargin: "100px" }
);
const imgPlaceholders = document.querySelectorAll(".lazy-image");
imgPlaceholders.forEach((img) => observer.observe(img));
return () => observer.disconnect();
}, [imageUrls]);
if (!imageUrls || imageUrls.length === 0) return null;
return (
<div className="mt-4 mb-4">
<h3 className="text-md font-semibold mb-2">Relevant Images:</h3>
<ScrollArea className="w-full h-[300px] overflow-auto border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm p-1" type="always">
<div className="p-2 grid grid-cols-2 md:grid-cols-4 gap-2">
{imageUrls.map((url, index) => (
<div key={index} className="image-container h-[150px]">
<img
className="lazy-image rounded-md w-full h-full object-cover shadow-sm border border-slate-100 dark:border-slate-800"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23f1f1f1'/%3E%3C/svg%3E"
data-src={url}
alt={`Research image ${index + 1}`}
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23f1f1f1'/%3E%3Ctext x='50%' y='50%' font-size='12' text-anchor='middle' alignment-baseline='middle' font-family='Arial, sans-serif'%3EImage failed to load%3C/text%3E%3C/svg%3E";
}}
/>
</div>
))}
</div>
</ScrollArea>
</div>
);
};
const MarkdownComponents: Record<string, React.ComponentType<any>> = {
h1: ({ children }) => <h1 className="text-2xl font-bold mb-4">{children}</h1>,
h2: ({ children }) => <h2 className="text-xl font-bold mb-3">{children}</h2>,
h3: ({ children }) => <h3 className="text-lg font-bold mb-3">{children}</h3>,
p: ({ children }) => <p className="mb-4 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc ml-6 mb-4">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal ml-6 mb-4">{children}</ol>,
li: ({ children }) => <li className="mb-1">{children}</li>,
code: ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const language = match ? match[1] : "";
return Object.keys(node.properties).length === 0 ? (
<code className="bg-muted px-1 py-0.5 rounded-md text-sm" {...props}>
{children}
</code>
) : (
<ScrollArea className="w-full max-w-full">
<SyntaxHighlighter
style={oneDark}
language={language || "text"}
PreTag="div"
className="rounded-md my-2 text-sm"
showLineNumbers
customStyle={{
margin: 0,
borderRadius: "0.5rem",
padding: "1rem",
}}
{...props}>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</ScrollArea>
);
},
pre: ({ children }) => <div className="bg-transparent p-0 max-w-full">{children}</div>,
a: ({ children, href }) => (
<a href={href} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
blockquote: ({ children }) => <blockquote className="border-l-4 border-border pl-4 italic my-4">{children}</blockquote>,
table: ({ children }) => (
<div className="overflow-x-scroll border rounded-2xl my-4">
<table className="w-max border-collapse rounded-2xl overflow-hidden shadow-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
th: ({ children }) => <th className="border-r last:border-r-0 border-slate-900 px-2 py-1 text-left font-semibold">{children}</th>,
tr: ({ children }) => <tr className="border-b last:border-b-0 border-border">{children}</tr>,
td: ({ children }) => <td className="border-r last:border-r-0 border-border px-2 py-1">{children}</td>,
};
interface MessageProps {
message: MessageType;
isLoading?: boolean;
connectionMode: "agent" | "workflow";
}
const Message = ({ message, isLoading, connectionMode }: MessageProps) => {
const isUser = message?.role === "user";
const [imageUrls, setImageUrls] = useState<string[]>([]);
const progressPercentage = message.progress || 0;
const isProgressMessage = message.isProgress === true && connectionMode === "workflow";
const [isSearchMessage, setIsSearchMessage] = useState(message.content.startsWith("s_"));
// Use useMemo to extract sources only once and only when they change
const sourceLinks = useMemo(() => {
// If this is the loading component, return empty sources
if (isLoading) return [];
// First, extract sources from the research_tree if available
const researchSources = extractAllSources(message.research_tree);
// If research_tree sources exist, use those
if (researchSources.length > 0) {
return researchSources;
}
// Otherwise, fall back to any links in the media object
return message.media?.links || [];
}, [message.research_tree, message.media?.links, isLoading]);
// Extract image URLs from the message content or use the media object
useEffect(() => {
if (isLoading) {
setImageUrls([]);
return;
}
if (!isUser) {
// Handle image URLs
let urls: string[] = [];
// First, check if there's a media object with images
if (message.media?.images && message.media.images.length > 0) {
urls = message.media.images;
}
setImageUrls(urls);
}
setIsSearchMessage(message.content.startsWith("s_"));
}, [message.content, message.media, isUser, isLoading]);
const copyToClipboard = () => {
if (!isLoading) {
navigator.clipboard.writeText(message.content);
}
};
return (
<div className="py-2 px-2 sm:px-4 sm:mx-12">
<div className={`w-full flex gap-4 relative ${isUser ? "justify-end" : "justify-start"}`}>
<Avatar className={`h-8 w-8 rounded-full bg-muted flex justify-center item-center absolute ${isUser ? "right-0 sm:-right-12" : "left-0 sm:-left-12"} top-0 hidden sm:flex`}>{isLoading || isProgressMessage ? <Loader2 className="h-full w-6 animate-spin" /> : isUser ? <User2 className="h-full w-6" /> : <Bot className="h-full w-6" />}</Avatar>
<div className={`max-w-full ${isLoading || isProgressMessage ? "w-[80%]" : ""} ${isUser ? "items-end ml-auto" : "items-start mr-auto"}`}>
<div className={`flex items-center gap-2 mb-1 ${isUser ? "justify-end" : "justify-start"}`}>
<div className="font-medium">{isUser ? "You" : "KNet"}</div>
{!isUser && !isLoading && <div className="text-xs text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</div>}
{(isLoading || isProgressMessage) && connectionMode === "workflow" && <div className="text-xs text-muted-foreground">Just now</div>}
{!isUser && !isLoading && !isProgressMessage && (
<div className="ml-auto flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={copyToClipboard}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy to clipboard</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyToClipboard}>Copy text</DropdownMenuItem>
<DropdownMenuItem>Show sources</DropdownMenuItem>
<DropdownMenuItem>View in visualizations</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className={`mt-1 w-full ${isUser ? "bg-slate-300 dark:bg-slate-200 dark:text-background text-foreground" : "bg-muted/50"} p-3 rounded-2xl ${isUser ? "rounded-tr-sm" : "rounded-tl-sm"}`} style={{ overflowWrap: "anywhere" }}>
{(isLoading || isProgressMessage) && connectionMode === "workflow" ? (
<div className="space-y-2">
<div>{message.content}</div>
<div className={`flex items-center ${isSearchMessage ? "justify-between" : "justify-end"} text-sm w-full`}>
{isSearchMessage ? (
<Badge variant="outline" className="bg-primary/20 text-primary rounded-full">
<SearchIcon className="h-3 w-3 mr-1" />
{message.content.slice(2)}
</Badge>
) : null}
<Badge variant="outline" className="ml-2 bg-primary/20 text-primary">
{progressPercentage}%
</Badge>
</div>
<div className="w-full bg-muted/30 h-2.5 rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-500 rounded-full flex items-center justify-end" style={{ width: `${progressPercentage}%` }}>
<div className="h-2 w-2 rounded-full bg-primary-foreground mr-0.5 animate-pulse"></div>
</div>
</div>
</div>
) : (
<>
{message.content && message.content.trim() !== "" && (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MarkdownComponents}>
{message.content}
</ReactMarkdown>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className={`${message.content && message.content.trim() !== "" ? "mt-3" : ""} space-y-2`}>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">Tool Usage</div>
{message.tool_calls.map((toolCall, index) => (
<ToolCallDisplay key={index} toolCall={toolCall} />
))}
</div>
)}
{sourceLinks.length > 0 && <SourceLinks links={sourceLinks} />}
{imageUrls.length > 0 && <ImageGallery imageUrls={imageUrls} />}
</>
)}
</div>
</div>
</div>
</div>
);
};
export default Message;