Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/src/features/deep-agent-chat/components/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { Assistant, Message } from "@langchain/langgraph-sdk";
import {
extractStringFromMessageContent,
isPreparingToCallTaskTool,
extractDocumentsFromMessage,
Document,
} from "../utils";
import { v4 as uuidv4 } from "uuid";
import { useQueryState } from "nuqs";
Expand Down Expand Up @@ -166,6 +168,27 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
agentId,
);

const sourceToDocumentsMap = useMemo(() => {
const documents = messages
.filter(
(message) =>
message.type === "tool" && typeof message.content === "string",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This is generic filter right now, we could filter to specific tool calls from Chat LangChain if we so chose

)
.map((message) =>
extractDocumentsFromMessage(message.content as string),
)
.flat();
return documents.reduce(
(acc, document) => {
if (document.source) {
acc[document.source] = document;
}
return acc;
},
{} as Record<string, Document>,
);
}, [messages]);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
Expand Down Expand Up @@ -444,6 +467,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
selectedSubAgent={selectedSubAgent}
onRestartFromAIMessage={handleRestartFromAIMessage}
onRestartFromSubTask={handleRestartFromSubTask}
sourceToDocumentsMap={sourceToDocumentsMap}
debugMode={debugMode}
isLoading={isLoading}
isLastMessage={index === processedMessages.length - 1}
Expand Down
25 changes: 22 additions & 3 deletions apps/web/src/features/deep-agent-chat/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import { User, Bot } from "lucide-react";
import { SubAgentIndicator } from "./SubAgentIndicator";
import { ToolCallBox } from "./ToolCallBox";
import { MarkdownContent } from "./MarkdownContent";
import { Citations } from "./Citations";
import type { SubAgent, ToolCall } from "../types";
import { Message } from "@langchain/langgraph-sdk";
import { extractStringFromMessageContent } from "../utils";
import {
extractStringFromMessageContent,
extractCitationUrls,
Document,
} from "../utils";
import { cn } from "@/lib/utils";

interface ChatMessageProps {
Expand All @@ -18,6 +23,7 @@ interface ChatMessageProps {
selectedSubAgent: SubAgent | null;
onRestartFromAIMessage: (message: Message) => void;
onRestartFromSubTask: (toolCallId: string) => void;
sourceToDocumentsMap: Record<string, Document>;
debugMode?: boolean;
isLastMessage?: boolean;
isLoading?: boolean;
Expand All @@ -32,6 +38,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
selectedSubAgent,
onRestartFromAIMessage,
onRestartFromSubTask,
sourceToDocumentsMap,
debugMode,
isLastMessage,
isLoading,
Expand Down Expand Up @@ -75,6 +82,10 @@ export const ChatMessage = React.memo<ChatMessageProps>(
}
}, [selectedSubAgent, onSelectSubAgent, subAgentsString, subAgents]);

const citations = useMemo(() => {
return extractCitationUrls(messageContent);
}, [messageContent]);

return (
<div
className={cn(
Expand Down Expand Up @@ -115,7 +126,15 @@ export const ChatMessage = React.memo<ChatMessageProps>(
{messageContent}
</p>
) : (
<MarkdownContent content={messageContent} />
<>
<MarkdownContent content={messageContent} />
{citations.length > 0 && (
<Citations
urls={citations}
sourceToDocumentsMap={sourceToDocumentsMap}
/>
)}
</>
)}
</div>
<div className="relative mt-4 w-[72px] flex-shrink-0">
Expand All @@ -131,7 +150,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
</div>
)}
{hasToolCalls && (
<div className="mt-4 flex w-fit max-w-full flex-col">
<div className="mt-4 flex w-fit max-w-full flex-col gap-4">
{toolCalls.map((toolCall: ToolCall) => {
if (toolCall.name === "task") return null;
return (
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/features/deep-agent-chat/components/Citations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import React, { useMemo } from "react";
import { ExternalLink } from "lucide-react";
import { Document } from "../utils";

interface CitationsProps {
urls: string[];
sourceToDocumentsMap: Record<string, Document>;
}

export const Citations = React.memo<CitationsProps>(
({ urls, sourceToDocumentsMap }) => {
if (urls.length === 0) return null;

return (
<div className="mt-3 space-y-1.5">
<div className="text-muted-foreground text-xs font-medium">
Sources ({urls.length})
</div>
<div className="flex flex-wrap gap-1.5">
{urls.map((url, index) => (
<Citation
key={`${url}-${index}`}
url={url}
document={sourceToDocumentsMap[url]}
/>
))}
</div>
</div>
);
},
);

Citations.displayName = "Citations";

const CHARACTER_LIMIT = 40;

interface CitationProps {
url: string;
document: Document | null;
}
export const Citation = React.memo<CitationProps>(({ url, document }) => {
const displayUrl =
url.length > CHARACTER_LIMIT
? url.substring(0, CHARACTER_LIMIT) + "..."
: url;

const favicon = useMemo(() => {
try {
const urlObj = new URL(url);
const domain = urlObj.hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
} catch {
return undefined;
}
}, [url]);

return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-2 py-1 transition-colors hover:border-gray-300 hover:bg-gray-50"
title={url}
>
<>
{favicon ? (
<img
src={favicon}
alt=""
className="h-3 w-3 flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = "none";
e.currentTarget.nextElementSibling?.classList.remove("hidden");
}}
/>
) : (
<ExternalLink className="h-3 w-3 flex-shrink-0 text-gray-400" />
)}
<span className="text-xs font-medium text-gray-700 group-hover:text-gray-900">
{document?.title || displayUrl}
</span>
</>
</a>
);
});

Citation.displayName = "Citation";
118 changes: 102 additions & 16 deletions apps/web/src/features/deep-agent-chat/components/ToolCallBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ import {
Loader,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ToolCall } from "../types";
import { extractDocumentsFromMessage, Document } from "../utils";
import { MarkdownContent } from "./MarkdownContent";
import { Citation } from "./Citations";

interface ToolCallBoxProps {
toolCall: ToolCall;
Expand Down Expand Up @@ -75,6 +84,13 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(({ toolCall }) => {

const hasContent = result || Object.keys(args).length > 0;

const documents = useMemo(() => {
if (result && typeof result === "string") {
return extractDocumentsFromMessage(result);
}
return [];
}, [result]);

return (
<div
className="w-fit overflow-hidden rounded-md border"
Expand Down Expand Up @@ -178,22 +194,33 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(({ toolCall }) => {
>
Result
</h4>
<pre
className="overflow-x-auto rounded-sm border font-mono break-all whitespace-pre-wrap"
style={{
fontSize: "12px",
padding: "0.5rem",
borderColor: "var(--color-border-light)",
lineHeight: "1.75",
margin: "0",
fontFamily:
'"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
}}
>
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
{documents.length > 0 ? (
<div className="space-y-2">
{documents.map((document, index) => (
<DocumentView
key={`${document.source}-${index}`}
document={document}
/>
))}
</div>
) : (
<pre
className="overflow-x-auto rounded-sm border font-mono break-all whitespace-pre-wrap"
style={{
fontSize: "12px",
padding: "0.5rem",
borderColor: "var(--color-border-light)",
lineHeight: "1.75",
margin: "0",
fontFamily:
'"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
}}
>
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
)}
</div>
)}
</div>
Expand All @@ -203,3 +230,62 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(({ toolCall }) => {
});

ToolCallBox.displayName = "ToolCallBox";

interface DocumentViewProps {
document: Document;
}

const DocumentView = React.memo<DocumentViewProps>(({ document }) => {
const [isOpen, setIsOpen] = useState(false);

return (
<>
<div
className="cursor-pointer rounded-md border p-3 transition-colors hover:bg-gray-50"
style={{
borderColor: "var(--color-border-light)",
}}
onClick={() => setIsOpen(true)}
>
<div className="mb-1 text-sm font-medium">
{document.title || "Untitled Document"}
</div>
<div className="text-muted-foreground line-clamp-2 text-xs">
{document.content
? document.content.substring(0, 150) + "..."
: "No content"}
</div>
</div>

<Dialog
open={isOpen}
onOpenChange={setIsOpen}
>
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-[60vw]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{document.title || "Document"}
</DialogTitle>
{document.source && (
<div className="pt-2">
<Citation
url={document.source}
document={document}
/>
</div>
)}
</DialogHeader>
<div className="mt-4">
{document.content ? (
<MarkdownContent content={document.content} />
) : (
<p className="text-muted-foreground">No content available</p>
)}
</div>
</DialogContent>
</Dialog>
</>
);
});

DocumentView.displayName = "DocumentView";
28 changes: 28 additions & 0 deletions apps/web/src/features/deep-agent-chat/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Deployment } from "@/types/deployment";
import { Message } from "@langchain/langgraph-sdk";

export interface Document {
title: string | null;
content: string | null;
source: string | null;
}

export function extractStringFromMessageContent(message: Message): string {
return typeof message.content === "string"
? message.content
Expand Down Expand Up @@ -68,3 +74,25 @@ export function deploymentSupportsDeepAgents(
) {
return deployment?.supportsDeepAgents ?? false;
}

export function extractCitationUrls(text: string): string[] {
return Array.from(
text.matchAll(/\[([^\]]*)\]\(([^)]*)\)/g),
(match) => match[2],
).filter((url, index, self) => self.indexOf(url) === index);
}

export function extractDocumentsFromMessage(content: string): Document[] {
try {
const toolResultContent = JSON.parse(content);
return toolResultContent["documents"].map((document: any) => {
return {
title: document.title,
content: document.page_content,
source: document.source,
} as Document;
});
} catch {
return [];
}
}