diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index bcf17ef75..141d3455b 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -119,12 +119,6 @@ export function ChatSidebar({ Record >({}) const [expandedMemories, setExpandedMemories] = useState(null) - const [followUpQuestions, setFollowUpQuestions] = useState< - Record - >({}) - const [loadingFollowUps, setLoadingFollowUps] = useState< - Record - >({}) const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) const [heightOffset, setHeightOffset] = useState(95) @@ -136,25 +130,24 @@ export function ChatSidebar({ const [confirmingDeleteId, setConfirmingDeleteId] = useState( null, ) - const pendingFollowUpGenerations = useRef>(new Set()) const messagesContainerRef = useRef(null) const sentQueuedMessageRef = useRef(null) const { selectedProject } = useProject() const { viewMode } = useViewMode() - const { user } = useAuth() + const { user: _user } = useAuth() const [threadId, setThreadId] = useQueryState("thread", threadParam) const [fallbackChatId, setFallbackChatId] = useState(() => generateId()) const currentChatId = threadId ?? fallbackChatId const chatIdRef = useRef(currentChatId) chatIdRef.current = currentChatId - const setCurrentChatId = useCallback( + const _setCurrentChatId = useCallback( (id: string) => setThreadId(id), [setThreadId], ) const chatTransport = useMemo( () => new DefaultChatTransport({ - api: `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/chat/v2`, + api: `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/chat`, credentials: "include", prepareSendMessagesRequest: ({ messages }) => ({ body: { @@ -196,15 +189,6 @@ export function ChatSidebar({ const { messages, sendMessage, status, setMessages, stop } = useChat({ id: currentChatId ?? undefined, transport: chatTransport, - onFinish: async (result) => { - if (result.message.role !== "assistant") return - - // Mark this message as needing follow-up generation - // We'll generate it after the message is fully in the messages array - if (result.message.id) { - pendingFollowUpGenerations.current.add(result.message.id) - } - }, }) useEffect(() => { @@ -214,100 +198,6 @@ export function ChatSidebar({ } }, [currentChatId, pendingThreadLoad, setMessages]) - // Generate follow-up questions after assistant messages are complete - useEffect(() => { - const generateFollowUps = async () => { - // Find assistant messages that need follow-up generation - const messagesToProcess = messages.filter( - (msg) => - msg.role === "assistant" && - pendingFollowUpGenerations.current.has(msg.id) && - !followUpQuestions[msg.id] && - !loadingFollowUps[msg.id], - ) - - for (const message of messagesToProcess) { - // Get complete text from the message - const assistantText = message.parts - .filter((p) => p.type === "text") - .map((p) => p.text) - .join(" ") - .trim() - - // Only generate if we have substantial text (at least 50 chars) - // This ensures the message is complete, not just the first chunk - // Also check if status is idle to ensure streaming is complete - if ( - assistantText.length < 50 || - status === "streaming" || - status === "submitted" - ) { - continue - } - - // Mark as processing - pendingFollowUpGenerations.current.delete(message.id) - setLoadingFollowUps((prev) => ({ - ...prev, - [message.id]: true, - })) - - try { - // Get recent messages for context - const recentMessages = messages.slice(-5).map((msg) => ({ - role: msg.role, - content: msg.parts - .filter((p) => p.type === "text") - .map((p) => p.text) - .join(" "), - })) - - const response = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/follow-ups`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - messages: recentMessages, - assistantResponse: assistantText, - }), - }, - ) - - if (response.ok) { - const data = await response.json() - if (data.questions && Array.isArray(data.questions)) { - setFollowUpQuestions((prev) => ({ - ...prev, - [message.id]: data.questions, - })) - } - } - } catch (error) { - console.error("Failed to generate follow-up questions:", error) - } finally { - setLoadingFollowUps((prev) => ({ - ...prev, - [message.id]: false, - })) - } - } - } - - // Only generate if not currently streaming or submitted - // Small delay to ensure message is fully processed - if (status !== "streaming" && status !== "submitted") { - const timeoutId = setTimeout(() => { - generateFollowUps() - }, 300) - - return () => clearTimeout(timeoutId) - } - }, [messages, followUpQuestions, loadingFollowUps, status]) - const checkIfScrolledToBottom = useCallback(() => { if (!messagesContainerRef.current) return const container = messagesContainerRef.current @@ -879,19 +769,10 @@ export function ChatSidebar({ copiedMessageId={copiedMessageId} messageFeedback={messageFeedback} expandedMemories={expandedMemories} - followUpQuestions={followUpQuestions[message.id] || []} - isLoadingFollowUps={loadingFollowUps[message.id] || false} onCopy={handleCopyMessage} onLike={handleLikeMessage} onDislike={handleDislikeMessage} onToggleMemories={handleToggleMemories} - onQuestionClick={(question) => { - analytics.chatFollowUpClicked({ - thread_id: currentChatId || undefined, - }) - analytics.chatMessageSent({ source: "follow_up" }) - setInput(question) - }} /> )} diff --git a/apps/web/components/chat/input/chain-of-thought.tsx b/apps/web/components/chat/input/chain-of-thought.tsx index b1923146d..9ad3d0595 100644 --- a/apps/web/components/chat/input/chain-of-thought.tsx +++ b/apps/web/components/chat/input/chain-of-thought.tsx @@ -1,16 +1,12 @@ import { useAuth } from "@lib/auth-context" import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" import type { UIMessage } from "@ai-sdk/react" -import { cn } from "@lib/utils" -import { dmSansClassName } from "@/lib/fonts" - -interface MemoryResult { - documentId?: string - title?: string - content?: string - url?: string - score?: number -} +import { + type ChatMemoryCard, + memoryResultsFromSearchToolOutput, +} from "@/lib/chat-search-memory-results" +import { isWebSearchToolName } from "@/lib/chat-web-search-tools" +import { MemorySearchResultCard } from "../message/memory-search-result-card" interface ReasoningStep { type: string @@ -28,7 +24,8 @@ export function ChainOfThought({ messages }: { messages: UIMessage[] }) { }> = [] for (let i = 0; i < messages.length; i++) { - const message = messages[i]! + const message = messages[i] + if (!message) continue if (message.role === "user") { // Find the next assistant message after this user message const agentMessage = messages @@ -76,24 +73,80 @@ export function ChainOfThought({ messages }: { messages: UIMessage[] }) { message: "Error searching memories", }) } + return + } + + const webSearchPart = + part.type === "dynamic-tool" && + isWebSearchToolName( + (part as { toolName?: string }).toolName ?? "", + ) + ? (part as { + type: "dynamic-tool" + toolName: string + state: string + }) + : part.type === "tool-web_search" || + part.type === "tool-google_search" + ? (part as { type: string; state: string }) + : null + + if (webSearchPart) { + if ( + webSearchPart.state === "input-available" || + webSearchPart.state === "input-streaming" + ) { + reasoningSteps.push({ + type: "web-search", + state: webSearchPart.state, + message: "Searching the web...", + }) + } else if (webSearchPart.state === "output-available") { + reasoningSteps.push({ + type: "web-search", + state: webSearchPart.state, + message: "Explored the web", + }) + } else if (webSearchPart.state === "output-error") { + reasoningSteps.push({ + type: "web-search", + state: webSearchPart.state, + message: "Web search failed", + }) + } } }) + + const webSourceCount = pair.agentMessage.parts.filter( + (p) => p.type === "source-url", + ).length + if (webSourceCount > 0) { + const hasToolWebDone = reasoningSteps.some( + (s) => s.type === "web-search" && s.state === "output-available", + ) + if (!hasToolWebDone) { + reasoningSteps.push({ + type: "web-sources", + state: "done", + message: + webSourceCount === 1 + ? "Found 1 web source" + : `Found ${webSourceCount} web sources`, + }) + } + } } - const memoryResults: MemoryResult[] = [] + const memoryResults: ChatMemoryCard[] = [] if (pair.agentMessage) { pair.agentMessage.parts.forEach((part) => { if ( part.type === "tool-searchMemories" && part.state === "output-available" ) { - const output = part.output as - | { results?: MemoryResult[] } - | undefined - const results = Array.isArray(output?.results) - ? output.results - : [] - memoryResults.push(...results) + memoryResults.push( + ...memoryResultsFromSearchToolOutput(part.output), + ) } }) } @@ -133,75 +186,14 @@ export function ChainOfThought({ messages }: { messages: UIMessage[] }) { )} {memoryResults.length > 0 && ( -
- {memoryResults.map((result, idx) => { - const isClickable = - result.url && - (result.url.startsWith("http://") || - result.url.startsWith("https://")) - - const content = ( -
-
- {result.title && ( -
- {result.title} -
- )} - {result.content && ( -
- {result.content} -
- )} - {result.url && ( -
- {result.url} -
- )} -
- {result.score && ( -
-
- Relevancy score:{" "} - {(result.score * 100).toFixed(1)}% -
-
- )} -
- ) - - if (isClickable) { - return ( - - {content} - - ) - } - - return ( -
- {content} -
- ) - })} +
+ {memoryResults.map((result, idx) => ( + + ))}
)}
diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index 3b3ecb22d..eaf151ce1 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -8,6 +8,7 @@ import { ChevronRightIcon, Loader2, SearchIcon, + GlobeIcon, PlusIcon, BookOpenIcon, ClockIcon, @@ -16,12 +17,14 @@ import { WrenchIcon, } from "lucide-react" import { cn } from "@lib/utils" +import { isWebSearchToolName } from "@/lib/chat-web-search-tools" import { RelatedMemories } from "./related-memories" import { MessageActions } from "./message-actions" -import { FollowUpQuestions } from "./follow-up-questions" const TOOL_META: Record = { searchMemories: { label: "Search Memories", icon: SearchIcon }, + web_search: { label: "Web search", icon: GlobeIcon }, + google_search: { label: "Google search", icon: GlobeIcon }, addMemory: { label: "Add Memory", icon: PlusIcon }, fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon }, scheduleTask: { label: "Schedule Task", icon: ClockIcon }, @@ -29,27 +32,85 @@ const TOOL_META: Record = { cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon }, } -function ToolCallDisplay({ - part, -}: { - part: { - type: string - state: string - input?: unknown - output?: unknown - toolCallId?: string - } -}) { +type ToolCallDisplayPart = { + type: string + state: string + input?: unknown + output?: unknown + toolCallId?: string + errorText?: string +} + +type SourceUrlPart = { + type: "source-url" + sourceId: string + url: string + title?: string +} + +function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) { + const [expanded, setExpanded] = useState(false) + if (sources.length === 0) return null + + return ( +
+ + {expanded && ( + + )} +
+ ) +} + +function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) { const [expanded, setExpanded] = useState(false) const toolName = part.type.replace("tool-", "") - const meta = TOOL_META[toolName] + const meta = + TOOL_META[toolName] ?? + (isWebSearchToolName(toolName) + ? { label: "Web search", icon: GlobeIcon } + : undefined) const Icon = meta?.icon ?? WrenchIcon const label = meta?.label ?? toolName const isLoading = part.state === "input-streaming" || part.state === "input-available" const isDone = part.state === "output-available" - const isError = part.state === "error" + const isError = part.state === "error" || part.state === "output-error" + const errorText = part.errorText return (
@@ -119,6 +180,14 @@ function ToolCallDisplay({
)} + {isError && errorText && ( +
+
Error
+
+								{errorText}
+							
+
+ )} )} @@ -133,13 +202,10 @@ interface AgentMessageProps { copiedMessageId: string | null messageFeedback: Record expandedMemories: string | null - followUpQuestions?: string[] - isLoadingFollowUps?: boolean onCopy: (messageId: string, text: string) => void onLike: (messageId: string) => void onDislike: (messageId: string) => void onToggleMemories: (messageId: string) => void - onQuestionClick?: (question: string) => void } export function AgentMessage({ @@ -150,13 +216,10 @@ export function AgentMessage({ copiedMessageId, messageFeedback, expandedMemories, - followUpQuestions = [], - isLoadingFollowUps = false, onCopy, onLike, onDislike, onToggleMemories, - onQuestionClick, }: AgentMessageProps) { const isLastAgentMessage = index === messagesLength - 1 && message.role === "assistant" @@ -177,6 +240,48 @@ export function AgentMessage({ /> {message.parts.map((part, partIndex) => { + if (part.type === "source-url") { + if ( + partIndex > 0 && + message.parts[partIndex - 1]?.type === "source-url" + ) { + return null + } + const sources: SourceUrlPart[] = [] + for (let j = partIndex; j < message.parts.length; j++) { + const p = message.parts[j] + if (!p || p.type !== "source-url") break + sources.push(p as SourceUrlPart) + } + return ( + + ) + } + if (part.type === "source-document") { + const doc = part as { + type: "source-document" + sourceId: string + title: string + filename?: string + } + return ( +
+
Document
+
{doc.title}
+ {doc.filename && ( +
+ {doc.filename} +
+ )} +
+ ) + } if (part.type === "text") { return (
) } + if (part.type === "dynamic-tool") { + const dt = part as { + type: "dynamic-tool" + toolName: string + toolCallId: string + state: string + input?: unknown + output?: unknown + errorText?: string + } + const displayState = + dt.state === "output-error" ? "error" : dt.state + return ( + + ) + } if (part.type.startsWith("tool-")) { return ( ) } return null })} - {})} - />
void - isLoading?: boolean -} - -export function FollowUpQuestions({ - questions, - onQuestionClick, - isLoading = false, -}: FollowUpQuestionsProps) { - if (isLoading) { - return ( -
-
-
-
- ) - } - - if (questions.length === 0) { - return null - } - - return ( -
-
Follow up questions:
-
- {questions.map((question) => ( - - ))} -
-
- ) -} diff --git a/apps/web/components/chat/message/memory-search-result-card.tsx b/apps/web/components/chat/message/memory-search-result-card.tsx new file mode 100644 index 000000000..1c3415846 --- /dev/null +++ b/apps/web/components/chat/message/memory-search-result-card.tsx @@ -0,0 +1,103 @@ +"use client" + +import { cn } from "@lib/utils" +import { + type ChatMemoryCard, + getMemoryCardDisplay, +} from "@/lib/chat-search-memory-results" +import { dmSansClassName } from "@/lib/fonts" + +type CardTone = "sidebar" | "input" + +function RelevancyScore({ score }: { score: number }) { + return ( +
+ + Relevancy score: {(score * 100).toFixed(1)}% + +
+ ) +} + +export function MemorySearchResultCard({ + result, + tone, +}: { + result: ChatMemoryCard + tone: CardTone +}) { + const { title, body } = getMemoryCardDisplay(result) + const isClickable = + result.url && + (result.url.startsWith("http://") || result.url.startsWith("https://")) + + const textBlock = ( +
+ {title ? ( +
+ {title} +
+ ) : null} +
+ {body || "—"} +
+ {result.url ? ( +
{result.url}
+ ) : null} +
+ ) + + const column = ( + <> + {textBlock} + {result.score != null ? : null} + + ) + + if (isClickable) { + const linkClass = + tone === "sidebar" + ? "flex h-full min-h-0 flex-col rounded-md border border-white/10 bg-white/5 p-2 transition-colors hover:bg-white/10" + : "flex h-full min-h-0 flex-col rounded-md border border-[#525D6E]/20 bg-[#0C1829]/50 p-2 transition-colors hover:bg-[#0C1829]/70" + + return ( + + {column} + + ) + } + + const solidClass = + tone === "sidebar" + ? cn( + "flex h-full min-h-0 flex-col rounded-xl bg-[#0C1829] p-1", + dmSansClassName(), + ) + : "flex h-full min-h-0 flex-col rounded-xl bg-[#0C1829] p-1" + + return
{column}
+} diff --git a/apps/web/components/chat/message/related-memories.tsx b/apps/web/components/chat/message/related-memories.tsx index ad83d03e2..452e3ebdf 100644 --- a/apps/web/components/chat/message/related-memories.tsx +++ b/apps/web/components/chat/message/related-memories.tsx @@ -1,16 +1,13 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" import type { UIMessage } from "@ai-sdk/react" +import { + type ChatMemoryCard, + memoryResultsFromSearchToolOutput, +} from "@/lib/chat-search-memory-results" +import { MemorySearchResultCard } from "./memory-search-result-card" import { dmSansClassName } from "@/lib/fonts" import { cn } from "@lib/utils" -interface MemoryResult { - documentId?: string - title?: string - content?: string - url?: string - score?: number -} - interface RelatedMemoriesProps { message: UIMessage expandedMemories: string | null @@ -22,16 +19,14 @@ export function RelatedMemories({ expandedMemories, onToggle, }: RelatedMemoriesProps) { - const memoryResults: MemoryResult[] = [] + const memoryResults: ChatMemoryCard[] = [] message.parts.forEach((part) => { if ( part.type === "tool-searchMemories" && part.state === "output-available" ) { - const output = part.output as { results?: MemoryResult[] } | undefined - const results = Array.isArray(output?.results) ? output.results : [] - memoryResults.push(...results) + memoryResults.push(...memoryResultsFromSearchToolOutput(part.output)) } }) @@ -60,74 +55,14 @@ export function RelatedMemories({ {isExpanded && ( -
- {memoryResults.map((result, idx) => { - const isClickable = - result.url && - (result.url.startsWith("http://") || - result.url.startsWith("https://")) - - const content = ( -
-
- {result.title && ( -
- {result.title} -
- )} - {result.content && ( -
- {result.content} -
- )} - {result.url && ( -
- {result.url} -
- )} -
- {result.score && ( -
-
- Relevancy score: {(result.score * 100).toFixed(1)}% -
-
- )} -
- ) - - if (isClickable) { - return ( - - {content} - - ) - } - - return ( -
- {content} -
- ) - })} +
+ {memoryResults.map((result, idx) => ( + + ))}
)}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index acfe8fa14..71fad27f4 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -110,12 +110,8 @@ export const analytics = { }) => safeCapture("highlight_clicked", props), // chat analytics - chatMessageSent: (props: { - source: "typed" | "suggested" | "highlight" | "follow_up" - }) => safeCapture("chat_message_sent", props), - - chatFollowUpClicked: (props: { thread_id?: string }) => - safeCapture("chat_follow_up_clicked", props), + chatMessageSent: (props: { source: "typed" | "suggested" | "highlight" }) => + safeCapture("chat_message_sent", props), chatSuggestedQuestionClicked: () => safeCapture("chat_suggested_question_clicked"), diff --git a/apps/web/lib/chat-search-memory-results.ts b/apps/web/lib/chat-search-memory-results.ts new file mode 100644 index 000000000..36280e9f0 --- /dev/null +++ b/apps/web/lib/chat-search-memory-results.ts @@ -0,0 +1,96 @@ +export interface ChatMemoryCard { + documentId?: string + title?: string + content?: string + url?: string + score?: number +} + +function normalizeMetadata( + metadata: unknown, +): Record | undefined { + if (!metadata || typeof metadata !== "object") return undefined + return metadata as Record +} + +function normalizeOne(raw: unknown): ChatMemoryCard { + if (!raw || typeof raw !== "object") return {} + const r = raw as Record + const meta = normalizeMetadata(r.metadata) + + const bodyText = + (typeof r.content === "string" && r.content) || + (typeof r.memory === "string" && r.memory) || + (typeof r.chunk === "string" && r.chunk) || + "" + + const titleFromMeta = + (meta && typeof meta.title === "string" && meta.title) || + (meta && typeof meta.name === "string" && meta.name) || + undefined + + const docs = Array.isArray(r.documents) ? r.documents : [] + const firstDoc = docs[0] as Record | undefined + const docTitle = + firstDoc && typeof firstDoc.title === "string" ? firstDoc.title : undefined + + const explicitTitle = + typeof r.title === "string" && r.title.trim() ? r.title.trim() : undefined + + const title = explicitTitle || titleFromMeta || docTitle || undefined + + const url = + (typeof r.url === "string" && r.url) || + (meta && typeof meta.url === "string" ? meta.url : undefined) || + (meta && typeof meta.sourceUrl === "string" ? meta.sourceUrl : undefined) + + const score = + typeof r.score === "number" + ? r.score + : typeof r.similarity === "number" + ? r.similarity + : undefined + + const documentId = + (typeof r.documentId === "string" && r.documentId) || + (typeof r.id === "string" && r.id) || + undefined + + return { + documentId, + title, + content: bodyText || undefined, + url, + score, + } +} + +/** Normalizes searchMemories tool output (v4 API shape or legacy chunk-search shape). */ +export function memoryResultsFromSearchToolOutput( + output: unknown, +): ChatMemoryCard[] { + if (!output || typeof output !== "object") return [] + const o = output as { results?: unknown } + if (!Array.isArray(o.results)) return [] + return o.results.map(normalizeOne) +} + +/** + * Avoid showing the same text twice when title was a truncated copy of content + * or legacy payloads duplicated both fields. + */ +export function getMemoryCardDisplay(result: ChatMemoryCard): { + title?: string + body: string +} { + const content = (result.content ?? "").trim() + const titleRaw = (result.title ?? "").trim() + if (!content && titleRaw) return { body: titleRaw } + if (!titleRaw) return { body: content } + if (titleRaw === content) return { body: content } + const base = titleRaw.endsWith("…") + ? titleRaw.slice(0, -1).trimEnd() + : titleRaw + if (base.length > 0 && content.startsWith(base)) return { body: content } + return { title: titleRaw, body: content } +} diff --git a/apps/web/lib/chat-web-search-tools.ts b/apps/web/lib/chat-web-search-tools.ts new file mode 100644 index 000000000..6a0936230 --- /dev/null +++ b/apps/web/lib/chat-web-search-tools.ts @@ -0,0 +1,9 @@ +export function isWebSearchToolName(name: string): boolean { + const n = name.toLowerCase() + return ( + n === "web_search" || + n === "google_search" || + n.includes("web_search") || + n === "websearch" + ) +}