supermemory/apps/web/components/chat/message/agent-message.tsx
2026-06-08 12:34:15 +05:30

687 lines
20 KiB
TypeScript

"use client"
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
GlobeIcon,
ListIcon,
Loader2,
PlusIcon,
SearchIcon,
TerminalIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import { modelNames, type ModelId } from "@/lib/models"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
bash: { label: "Memory", icon: TerminalIcon },
web_search: { label: "Web search", icon: GlobeIcon },
google_search: { label: "Google search", icon: GlobeIcon },
// legacy tool names kept for existing persisted messages
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
listSchedules: { label: "List Schedules", icon: ListIcon },
cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon },
}
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 sourceHost(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "")
} catch {
return url
}
}
function faviconUrl(host: string): string {
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
}
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
if (part.type === "dynamic-tool") {
return isWebSearchToolName(part.toolName ?? "")
}
if (part.type.startsWith("tool-")) {
return isWebSearchToolName(part.type.slice("tool-".length))
}
return false
}
function CitationLink({
href,
label,
source,
}: {
href: string
label: string
source?: SourceUrlPart
}) {
const url = source?.url ?? href
const host = sourceHost(url)
const rawTitle = source?.title?.trim()
const hasTitle =
!!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle)
let path = ""
try {
path = new URL(url).pathname.replace(/\/$/, "")
} catch {}
return (
<span className="group/cite relative inline">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 no-underline hover:text-blue-300"
>
{label}
</a>
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden -translate-x-1/2 pb-1.5 group-hover/cite:block">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto block w-64 overflow-hidden rounded-lg border border-white/10 bg-[#0B0F16]/95 p-2.5 no-underline shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl"
>
<span className="flex items-center gap-2">
<img
src={faviconUrl(host)}
alt=""
className="size-4 shrink-0 rounded bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="truncate text-xs font-medium text-white/85">
{host}
</span>
</span>
{hasTitle ? (
<span className="mt-1.5 block line-clamp-2 text-xs leading-snug text-white/60">
{rawTitle}
</span>
) : path ? (
<span className="mt-1 block truncate text-[11px] text-white/35">
{path}
</span>
) : null}
</a>
</span>
</span>
)
}
function makeMarkdownComponents(sources: SourceUrlPart[]) {
return {
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
const label =
typeof children === "string"
? children
: Array.isArray(children)
? children.join("")
: ""
const match = label.match(/^\[?(\d+)\]?$/)
if (match && href) {
const n = Number(match[1])
const source = sources.find((s) => s.url === href) ?? sources[n - 1]
return <CitationLink href={href} label={label} source={source} />
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{children}
</a>
)
},
}
}
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
const [expanded, setExpanded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!expanded) return
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false)
}
}
document.addEventListener("mousedown", onDown)
return () => document.removeEventListener("mousedown", onDown)
}, [expanded])
if (sources.length === 0) return null
const faviconHosts: string[] = []
for (const s of sources) {
const host = sourceHost(s.url)
if (!faviconHosts.includes(host)) faviconHosts.push(host)
if (faviconHosts.length >= 3) break
}
const count = sources.length
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
aria-expanded={expanded}
aria-label={`${count} web ${count === 1 ? "source" : "sources"}`}
>
<span className="flex -space-x-1.5">
{faviconHosts.length > 0 ? (
faviconHosts.map((host) => (
<img
key={host}
src={faviconUrl(host)}
alt=""
className="size-4 rounded-full border border-[#0B0F16] bg-white/10 object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
))
) : (
<GlobeIcon className="size-3.5 text-emerald-400" />
)}
</span>
<span>{count === 1 ? "1 source" : `${count} sources`}</span>
</button>
{expanded && (
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
{sources.map((s) => {
const host = sourceHost(s.url)
return (
<li key={s.sourceId}>
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
>
<img
src={faviconUrl(host)}
alt=""
className="mt-0.5 size-4 shrink-0 rounded-full bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="min-w-0">
<span className="block truncate text-white/80">
{s.title?.trim() || host}
</span>
<span className="block truncate text-[11px] text-white/40">
{host}
</span>
</span>
</a>
</li>
)
})}
</ul>
</div>
)}
</div>
)
}
function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const isLoading =
part.state === "input-streaming" || part.state === "input-available"
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"
const cmd =
part.input && typeof part.input === "object" && "cmd" in part.input
? String((part.input as { cmd: string }).cmd)
: undefined
const output =
isDone && part.output && typeof part.output === "object"
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
: undefined
const hasOutput =
output &&
((output.stdout && output.stdout.length > 0) ||
(output.stderr && output.stderr.length > 0))
const errorText = part.errorText
const hasExpandable = hasOutput || (isError && errorText)
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
<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 && hasExpandable && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<TerminalIcon
className={cn(
"size-3 shrink-0",
isDone
? output?.exitCode === 0
? "text-emerald-400"
: "text-amber-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
$
</span>
<span
className={cn(
"flex-1 text-left truncate",
isDone
? output?.exitCode === 0
? "text-emerald-300"
: "text-amber-300"
: isLoading
? "text-blue-300"
: isError
? "text-red-300"
: "text-white/70",
)}
>
{cmd ?? "…"}
</span>
{isLoading && <span className="text-white/30 shrink-0">running</span>}
{isDone && !hasOutput && (
<span className="text-white/30 shrink-0">done</span>
)}
{isError && <span className="text-red-400/60 shrink-0">error</span>}
{hasExpandable &&
(expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
))}
</button>
{expanded && (hasOutput || (isError && errorText)) && (
<div className="px-3 py-2 space-y-1">
{output?.stdout && output.stdout.length > 0 && (
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stdout}
</pre>
)}
{output?.stderr && output.stderr.length > 0 && (
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stderr}
</pre>
)}
{isError && errorText && (
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{errorText}
</pre>
)}
</div>
)}
</div>
)
}
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
if (isWebSearchToolName(toolName)) {
if (part.state === "output-available") return null
if (part.state === "error" || part.state === "output-error") {
return (
<div className="my-1 flex items-center gap-2 text-xs text-red-400/80">
<GlobeIcon className="size-3.5 shrink-0" />
<span>Web search failed</span>
</div>
)
}
return (
<div className="my-1 flex items-center gap-2 text-xs text-white/50">
<Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400/80" />
<span>Searching the web</span>
</div>
)
}
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" || part.state === "output-error"
const errorText = part.errorText
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]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<Icon
className={cn(
"size-3 shrink-0",
isDone
? "text-emerald-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span
className={cn(
"font-medium",
isDone
? "text-emerald-400"
: isError
? "text-red-400"
: "text-blue-400",
)}
>
{label}
</span>
{isLoading && <span className="text-white/40 ml-auto">running</span>}
{isDone && <span className="text-white/40 ml-auto">done</span>}
{isError && <span className="text-red-400/60 ml-auto">error</span>}
{expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
)}
</button>
{expanded && (
<div className="px-3 py-2 space-y-2">
{part.input !== undefined && (
<div>
<div className="text-white/40 mb-1">Input</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.input === "string"
? part.input
: JSON.stringify(part.input, null, 2)}
</pre>
</div>
)}
{isDone && part.output !== undefined && (
<div>
<div className="text-white/40 mb-1">Output</div>
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-40 overflow-y-auto whitespace-pre-wrap break-all">
{typeof part.output === "string"
? part.output
: JSON.stringify(part.output, null, 2)}
</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>
)
}
interface AgentMessageProps {
message: UIMessage
index: number
messagesLength: number
hoveredMessageId: string | null
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
expandedMemories: string | null
responseModel: ModelId | null
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
onToggleMemories: (messageId: string) => void
}
export function AgentMessage({
message,
index,
messagesLength,
hoveredMessageId,
copiedMessageId,
messageFeedback,
expandedMemories,
responseModel,
onCopy,
onLike,
onDislike,
onToggleMemories,
}: AgentMessageProps) {
const isLastAgentMessage =
index === messagesLength - 1 && message.role === "assistant"
const isHovered = hoveredMessageId === message.id
const messageText = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const webSources = (() => {
const seen = new Set<string>()
const out: SourceUrlPart[] = []
for (const part of message.parts) {
if (part.type !== "source-url") continue
const source = part as SourceUrlPart
if (seen.has(source.url)) continue
seen.add(source.url)
out.push(source)
}
return out
})()
const hasAssistantText = message.parts.some(
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
)
const sourceKey = webSources.map((s) => s.url).join("|")
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by stable source urls
const markdownComponents = useMemo(
() => makeMarkdownComponents(webSources),
[sourceKey],
)
const responseModelLabel = responseModel
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
: null
return (
<div className="flex flex-col gap-1 w-full">
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-full">
<RelatedMemories
message={message}
expandedMemories={expandedMemories}
onToggle={onToggleMemories}
/>
{message.parts.map((part, partIndex) => {
if (part.type === "source-url") {
return null
}
if (isWebSearchPart(part)) {
return null
}
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") {
// Skip fragments mid-run — source-url citations split one answer into
// many text parts; rendering each separately tears markdown (lists etc.).
let prev = partIndex - 1
while (prev >= 0 && message.parts[prev]?.type === "source-url") {
prev--
}
if (prev >= 0 && message.parts[prev]?.type === "text") {
return null
}
let runText = ""
for (let j = partIndex; j < message.parts.length; j++) {
const p = message.parts[j]
if (p?.type === "text") runText += p.text
else if (p?.type === "source-url") continue
else break
}
return (
<div
key={`${message.id}-${partIndex}`}
className="text-sm text-white/90 chat-markdown-content"
>
<Streamdown components={markdownComponents}>
{runText}
</Streamdown>
</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 ToolCallDisplayPart}
/>
)
}
return null
})}
</div>
</div>
{hasAssistantText && (
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
{webSources.length > 0 && (
<div
className={cn(
"transition-opacity duration-200",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<WebSourcesPill sources={webSources} />
</div>
)}
{responseModelLabel && (
<span
className={cn(
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
{responseModelLabel}
</span>
)}
</div>
)}
</div>
)
}