feat: web search and improve chat with tools (#810)

This commit is contained in:
MaheshtheDev 2026-03-27 01:57:13 +00:00
parent 694ad8123a
commit 57748ead4d
9 changed files with 461 additions and 378 deletions

View file

@ -119,12 +119,6 @@ export function ChatSidebar({
Record<string, "like" | "dislike" | null>
>({})
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
const [followUpQuestions, setFollowUpQuestions] = useState<
Record<string, string[]>
>({})
const [loadingFollowUps, setLoadingFollowUps] = useState<
Record<string, boolean>
>({})
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<string | null>(
null,
)
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
const messagesContainerRef = useRef<HTMLDivElement>(null)
const sentQueuedMessageRef = useRef<string | null>(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)
}}
/>
)}
</div>

View file

@ -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 && (
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 px-[10px] rounded-xl m-[2px]">
{result.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-[#525D6E]/80 line-clamp-2 mt-1">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-[#525D6E] mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="flex justify-center p-1">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score:{" "}
{(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-[#0C1829]/50 rounded-md border border-[#525D6E]/20 hover:bg-[#0C1829]/70 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl")}
key={result.documentId || idx}
>
{content}
</div>
)
})}
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto items-stretch">
{memoryResults.map((result, idx) => (
<MemorySearchResultCard
key={result.documentId ?? idx}
result={result}
tone="input"
/>
))}
</div>
)}
</div>

View file

@ -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<string, { label: string; icon: typeof SearchIcon }> = {
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<string, { label: string; icon: typeof SearchIcon }> = {
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 (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && "border-b border-[#1E2128]",
)}
>
<GlobeIcon className="size-3 shrink-0 text-emerald-400" />
<span className="font-medium text-emerald-400">
Web sources
<span className="text-white/40 font-normal ml-1">
({sources.length})
</span>
</span>
{expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0 ml-auto" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0 ml-auto" />
)}
</button>
{expanded && (
<ul className="px-3 py-2 space-y-1.5 list-none">
{sources.map((s) => (
<li key={s.sourceId}>
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline break-all"
>
{s.title?.trim() || s.url}
</a>
</li>
))}
</ul>
)}
</div>
)
}
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 (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
@ -119,6 +180,14 @@ function ToolCallDisplay({
</pre>
</div>
)}
{isError && errorText && (
<div>
<div className="text-red-400/80 mb-1">Error</div>
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 whitespace-pre-wrap break-all text-xs">
{errorText}
</pre>
</div>
)}
</div>
)}
</div>
@ -133,13 +202,10 @@ interface AgentMessageProps {
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
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 (
<WebSourcesGroup
key={`${message.id}-web-sources-${partIndex}`}
sources={sources}
/>
)
}
if (part.type === "source-document") {
const doc = part as {
type: "source-document"
sourceId: string
title: string
filename?: string
}
return (
<div
key={`${message.id}-doc-${doc.sourceId}-${partIndex}`}
className="rounded-lg border border-[#1E2128] bg-[#0D121A] px-3 py-2 text-xs my-1"
>
<div className="text-white/40 mb-0.5">Document</div>
<div className="text-white/80">{doc.title}</div>
{doc.filename && (
<div className="text-white/50 text-[11px] mt-0.5">
{doc.filename}
</div>
)}
</div>
)
}
if (part.type === "text") {
return (
<div
@ -187,21 +292,43 @@ export function AgentMessage({
</div>
)
}
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 (
<ToolCallDisplay
key={`${message.id}-${dt.toolCallId}-${partIndex}`}
part={{
type: `tool-${dt.toolName}`,
state: displayState,
input: dt.input,
output:
dt.state === "output-available" ? dt.output : undefined,
toolCallId: dt.toolCallId,
errorText: dt.errorText,
}}
/>
)
}
if (part.type.startsWith("tool-")) {
return (
<ToolCallDisplay
key={`${message.id}-${partIndex}`}
part={part as any}
part={part as ToolCallDisplayPart}
/>
)
}
return null
})}
<FollowUpQuestions
questions={followUpQuestions}
isLoading={isLoadingFollowUps}
onQuestionClick={onQuestionClick || (() => {})}
/>
</div>
</div>
<MessageActions

View file

@ -1,56 +0,0 @@
"use client"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@lib/utils"
interface FollowUpQuestionsProps {
questions: string[]
onQuestionClick: (question: string) => void
isLoading?: boolean
}
export function FollowUpQuestions({
questions,
onQuestionClick,
isLoading = false,
}: FollowUpQuestionsProps) {
if (isLoading) {
return (
<div className="mt-3 flex flex-col gap-2">
<div
key="skeleton-0"
className="h-4 w-28 animate-pulse rounded-full bg-white/10"
/>
<div
key="skeleton-1"
className="h-4 w-36 animate-pulse rounded-full bg-white/10"
/>
</div>
)
}
if (questions.length === 0) {
return null
}
return (
<div className="flex flex-wrap mt-2 gap-3">
<div className="text-xs">Follow up questions:</div>
<div className="flex flex-wrap">
{questions.map((question) => (
<button
key={question}
type="button"
onClick={() => onQuestionClick(question)}
className={cn(
"group flex items-center gap-1.5 rounded-full py-1 text-sm text-[#267BF1] transition-all hover:underline cursor-pointer text-start",
)}
>
<ArrowRightIcon className="size-3.5" />
<span>{question}</span>
</button>
))}
</div>
</div>
)
}

View file

@ -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 (
<div
className={cn(
"flex shrink-0 justify-center pt-2 pb-1.5",
dmSansClassName(),
)}
>
<span
className={cn(
"text-[10px] font-medium text-transparent bg-clip-text",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score: {(score * 100).toFixed(1)}%
</span>
</div>
)
}
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 = (
<div className="min-h-0 flex-1 rounded-lg bg-[#060D17] p-2">
{title ? (
<div className="mb-1 line-clamp-1 text-xs font-medium text-[#525D6E]/90">
{title}
</div>
) : null}
<div
className={cn(
"line-clamp-3 text-xs text-[#525D6E]",
tone === "input" && "text-[#525D6E]/80",
)}
>
{body || "—"}
</div>
{result.url ? (
<div className="mt-1 truncate text-xs text-[#525D6E]">{result.url}</div>
) : null}
</div>
)
const column = (
<>
{textBlock}
{result.score != null ? <RelevancyScore score={result.score} /> : 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 (
<a
className={cn(linkClass, "cursor-pointer")}
href={result.url}
rel="noopener noreferrer"
target="_blank"
>
{column}
</a>
)
}
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 <div className={solidClass}>{column}</div>
}

View file

@ -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({
</button>
{isExpanded && (
<div className="mt-2 grid grid-cols-2 gap-2 max-h-64 overflow-y-auto items-start">
{memoryResults.map((result, idx) => {
const isClickable =
result.url &&
(result.url.startsWith("http://") ||
result.url.startsWith("https://"))
const content = (
<div className="">
<div className="bg-[#060D17] p-2 rounded-t-[11px] rounded-b-[6px] m-[2px]">
{result.title && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.title}
</div>
)}
{result.content && (
<div className="text-xs text-[#525D6E] line-clamp-2">
{result.content}
</div>
)}
{result.url && (
<div className="text-xs text-[#525D6E] mt-1 truncate">
{result.url}
</div>
)}
</div>
{result.score && (
<div className="flex justify-center p-[4px]">
<div
className={cn(
"text-[10px] inline-block bg-clip-text text-transparent font-medium",
dmSansClassName(),
)}
style={{
backgroundImage:
"var(--grad-1, linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%))",
}}
>
Relevancy score: {(result.score * 100).toFixed(1)}%
</div>
</div>
)}
</div>
)
if (isClickable) {
return (
<a
className="block p-2 bg-white/5 rounded-md border border-white/10 hover:bg-white/10 transition-colors cursor-pointer"
href={result.url}
key={result.documentId || idx}
rel="noopener noreferrer"
target="_blank"
>
{content}
</a>
)
}
return (
<div
className={cn("bg-[#0C1829] rounded-xl", dmSansClassName())}
key={result.documentId || idx}
>
{content}
</div>
)
})}
<div className="mt-2 grid grid-cols-2 gap-2 max-h-64 overflow-y-auto items-stretch">
{memoryResults.map((result, idx) => (
<MemorySearchResultCard
key={result.documentId ?? idx}
result={result}
tone="sidebar"
/>
))}
</div>
)}
</div>

View file

@ -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"),

View file

@ -0,0 +1,96 @@
export interface ChatMemoryCard {
documentId?: string
title?: string
content?: string
url?: string
score?: number
}
function normalizeMetadata(
metadata: unknown,
): Record<string, unknown> | undefined {
if (!metadata || typeof metadata !== "object") return undefined
return metadata as Record<string, unknown>
}
function normalizeOne(raw: unknown): ChatMemoryCard {
if (!raw || typeof raw !== "object") return {}
const r = raw as Record<string, unknown>
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<string, unknown> | 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 }
}

View file

@ -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"
)
}