mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
added chat pane with lot of functionality
This commit is contained in:
parent
51fb9ddfc7
commit
bf1f74fac4
7 changed files with 522 additions and 58 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
|
|
@ -22,6 +22,7 @@ import type { ModelId } from "@/lib/models"
|
|||
import { SuperLoader } from "../superloader"
|
||||
import { UserMessage } from "./message/user-message"
|
||||
import { AgentMessage } from "./message/agent-message"
|
||||
import { ChainOfThought } from "./input/chain-of-thought"
|
||||
|
||||
export function ChatSidebar() {
|
||||
const [input, setInput] = useState("")
|
||||
|
|
@ -29,10 +30,18 @@ export function ChatSidebar() {
|
|||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
||||
const [chainOfThought, setChainOfThought] = useState<any[] | null>(null)
|
||||
const [messageFeedback, setMessageFeedback] = useState<
|
||||
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 pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
||||
const { selectedProject } = useProject()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
|
||||
|
|
@ -48,8 +57,113 @@ export function ChatSidebar() {
|
|||
},
|
||||
}),
|
||||
maxSteps: 10,
|
||||
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 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])
|
||||
|
||||
console.log(messages)
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || status === "submitted" || status === "streaming")
|
||||
return
|
||||
|
|
@ -197,7 +311,12 @@ export function ChatSidebar() {
|
|||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 scrollbar-thin">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto px-4 scrollbar-thin",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{messages.length === 0 && <ChatEmptyStatePlaceholder />}
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -237,10 +356,15 @@ 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) => {
|
||||
setInput(question)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -254,16 +378,39 @@ export function ChatSidebar() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 pt-2">
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={status === "submitted" || status === "streaming"}
|
||||
/>
|
||||
</div>
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={status === "submitted" || status === "streaming"}
|
||||
activeStatus={
|
||||
status === "submitted"
|
||||
? "Thinking..."
|
||||
: status === "streaming"
|
||||
? "Structuring response..."
|
||||
: "Waiting for input..."
|
||||
}
|
||||
chainOfThoughtComponent={(() => {
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "user")
|
||||
const lastAgentMessage = [...messages]
|
||||
.reverse()
|
||||
.find((msg) => msg.role === "assistant")
|
||||
const userMessageText =
|
||||
lastUserMessage?.parts.find((part) => part.type === "text")
|
||||
?.text ?? ""
|
||||
|
||||
return userMessageText ? (
|
||||
<ChainOfThought
|
||||
userMessage={userMessageText}
|
||||
lastAgentMessage={lastAgentMessage}
|
||||
/>
|
||||
) : null
|
||||
})()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
38
apps/web/components/chat/input/actions.tsx
Normal file
38
apps/web/components/chat/input/actions.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { ArrowUpIcon, SquareIcon } from "lucide-react"
|
||||
|
||||
export function SendButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 transition-opacity",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-[#161F2C]",
|
||||
)}
|
||||
>
|
||||
<ArrowUpIcon className="size-5 text-white" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StopButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
|
||||
>
|
||||
<SquareIcon className="size-4 text-white fill-white" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
186
apps/web/components/chat/input/chain-of-thought.tsx
Normal file
186
apps/web/components/chat/input/chain-of-thought.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
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 "@/utils/fonts"
|
||||
|
||||
interface MemoryResult {
|
||||
documentId?: string
|
||||
title?: string
|
||||
content?: string
|
||||
url?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface ReasoningStep {
|
||||
type: string
|
||||
state?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ChainOfThought({
|
||||
userMessage,
|
||||
lastAgentMessage,
|
||||
}: {
|
||||
userMessage: string
|
||||
lastAgentMessage: UIMessage | undefined
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
|
||||
const reasoningSteps: ReasoningStep[] = []
|
||||
if (lastAgentMessage) {
|
||||
lastAgentMessage.parts.forEach((part) => {
|
||||
if (part.type === "tool-searchMemories") {
|
||||
if (
|
||||
part.state === "input-available" ||
|
||||
part.state === "input-streaming"
|
||||
) {
|
||||
reasoningSteps.push({
|
||||
type: part.type,
|
||||
state: part.state,
|
||||
message: "Searching memories...",
|
||||
})
|
||||
} else if (part.state === "output-available") {
|
||||
reasoningSteps.push({
|
||||
type: part.type,
|
||||
state: part.state,
|
||||
message: "Found relevant memories",
|
||||
})
|
||||
} else if (part.state === "output-error") {
|
||||
reasoningSteps.push({
|
||||
type: part.type,
|
||||
state: part.state,
|
||||
message: "Error searching memories",
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const memoryResults: MemoryResult[] = []
|
||||
if (lastAgentMessage) {
|
||||
lastAgentMessage.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-3 mb-0 space-y-3 relative z-10">
|
||||
<div className="absolute left-[11px] top-0 bottom-0 w-[1px] bg-[#151F31] self-stretch mb-0 z-[-10]" />
|
||||
|
||||
<div className="flex items-start gap-3 text-[#525D6E] py-1 bg-[#01173C]">
|
||||
{user && (
|
||||
<Avatar className="size-[21px]">
|
||||
<AvatarImage src={user?.image ?? ""} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{user?.name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<p>{userMessage}</p>
|
||||
</div>
|
||||
|
||||
{(reasoningSteps.length > 0 || memoryResults.length > 0) && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center mx-2 bg-[#01173C] py-1 h-fit">
|
||||
<div className="size-[7px] rounded-full bg-[#151F31] shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
{reasoningSteps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{reasoningSteps.map((step, idx) => (
|
||||
<div
|
||||
key={`${step.type}-${step.state}-${idx}`}
|
||||
className="text-[#525D6E]"
|
||||
>
|
||||
{step.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { ArrowUpIcon, ChevronUpIcon, SquareIcon } from "lucide-react"
|
||||
import NovaOrb from "../nova/nova-orb"
|
||||
import { ChevronUpIcon } from "lucide-react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
|
|
@ -13,6 +15,8 @@ interface ChatInputProps {
|
|||
onStop: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
isResponding?: boolean
|
||||
activeStatus?: string
|
||||
chainOfThoughtComponent?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
|
|
@ -22,8 +26,11 @@ export default function ChatInput({
|
|||
onStop,
|
||||
onKeyDown,
|
||||
isResponding = false,
|
||||
activeStatus,
|
||||
chainOfThoughtComponent,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
|
|
@ -40,16 +47,59 @@ export default function ChatInput({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#01173C] rounded-xl">
|
||||
<div className=" p-3 pr-4 flex items-center justify-between">
|
||||
<motion.div
|
||||
className={cn("bg-[#01173C] relative")}
|
||||
animate={{
|
||||
padding: isExpanded ? "0.5rem" : "0",
|
||||
margin: isExpanded ? "0" : "0.5rem",
|
||||
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#01173C]",
|
||||
isExpanded
|
||||
? "max-h-[80vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-2"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isExpanded ? 50 : 0,
|
||||
}}
|
||||
>
|
||||
{isExpanded && (
|
||||
<div className="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[#01173C] via-[#01173C]/50 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{chainOfThoughtComponent}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NovaOrb size={24} className="!blur-none z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
Waiting for input...
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronUpIcon className="size-4 text-[#525D6E]" />
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
className={cn(
|
||||
"size-4 text-[#525D6E] transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
|
||||
|
|
@ -75,42 +125,6 @@ export default function ChatInput({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SendButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 transition-opacity",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-[#161F2C]",
|
||||
)}
|
||||
>
|
||||
<ArrowUpIcon className="size-5 text-white" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StopButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg flex-shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
|
||||
>
|
||||
<SquareIcon className="size-4 text-white fill-white" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { UIMessage } from "@ai-sdk/react"
|
|||
import { Streamdown } from "streamdown"
|
||||
import { RelatedMemories } from "./related-memories"
|
||||
import { MessageActions } from "./message-actions"
|
||||
import { FollowUpQuestions } from "./follow-up-questions"
|
||||
|
||||
interface AgentMessageProps {
|
||||
message: UIMessage
|
||||
|
|
@ -13,10 +14,13 @@ 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({
|
||||
|
|
@ -27,10 +31,13 @@ export function AgentMessage({
|
|||
copiedMessageId,
|
||||
messageFeedback,
|
||||
expandedMemories,
|
||||
followUpQuestions = [],
|
||||
isLoadingFollowUps = false,
|
||||
onCopy,
|
||||
onLike,
|
||||
onDislike,
|
||||
onToggleMemories,
|
||||
onQuestionClick,
|
||||
}: AgentMessageProps) {
|
||||
const isLastAgentMessage =
|
||||
index === messagesLength - 1 && message.role === "assistant"
|
||||
|
|
@ -78,6 +85,11 @@ export function AgentMessage({
|
|||
}
|
||||
return null
|
||||
})}
|
||||
<FollowUpQuestions
|
||||
questions={followUpQuestions}
|
||||
isLoading={isLoadingFollowUps}
|
||||
onQuestionClick={onQuestionClick || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MessageActions
|
||||
|
|
|
|||
56
apps/web/components/chat/message/follow-up-questions.tsx
Normal file
56
apps/web/components/chat/message/follow-up-questions.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -84,8 +84,19 @@ export function RelatedMemories({
|
|||
)}
|
||||
</div>
|
||||
{result.score && (
|
||||
<div className="text-xs text-white/50 mt-1 p-1 text-center">
|
||||
Relevance Score: {(result.score * 100).toFixed(1)}%
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue