mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 16:13:19 +00:00
feat: web search and improve chat with tools (#810)
This commit is contained in:
parent
694ad8123a
commit
57748ead4d
9 changed files with 461 additions and 378 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
103
apps/web/components/chat/message/memory-search-result-card.tsx
Normal file
103
apps/web/components/chat/message/memory-search-result-card.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
96
apps/web/lib/chat-search-memory-results.ts
Normal file
96
apps/web/lib/chat-search-memory-results.ts
Normal 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 }
|
||||
}
|
||||
9
apps/web/lib/chat-web-search-tools.ts
Normal file
9
apps/web/lib/chat-web-search-tools.ts
Normal 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"
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue