mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-21 02:08:25 +00:00
added chat pane items
This commit is contained in:
parent
ed6b6fb2fc
commit
51fb9ddfc7
15 changed files with 1234 additions and 318 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { Header } from "@/components/new/header"
|
||||
import { ChatSidebar } from "@/components/new/chat-sidebar"
|
||||
import { ChatSidebar } from "@/components/chat"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { MemoriesGrid } from "@/components/new/memories-grid"
|
||||
|
||||
|
|
|
|||
309
apps/web/components/chat/index.tsx
Normal file
309
apps/web/components/chat/index.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
HistoryIcon,
|
||||
PanelRightCloseIcon,
|
||||
SearchIcon,
|
||||
SquarePenIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import ChatInput from "./input"
|
||||
import ChatModelSelector from "./model-selector"
|
||||
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
|
||||
import { useProject, usePersistentChat } from "@/stores"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { SuperLoader } from "../superloader"
|
||||
import { UserMessage } from "./message/user-message"
|
||||
import { AgentMessage } from "./message/agent-message"
|
||||
|
||||
export function ChatSidebar() {
|
||||
const [input, setInput] = useState("")
|
||||
const [isChatOpen, setIsChatOpen] = useState(true)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
|
||||
const [messageFeedback, setMessageFeedback] = useState<
|
||||
Record<string, "like" | "dislike" | null>
|
||||
>({})
|
||||
const [expandedMemories, setExpandedMemories] = useState<string | null>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
|
||||
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat`,
|
||||
credentials: "include",
|
||||
body: {
|
||||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: selectedModel,
|
||||
},
|
||||
},
|
||||
}),
|
||||
maxSteps: 10,
|
||||
})
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || status === "submitted" || status === "streaming")
|
||||
return
|
||||
sendMessage({ text: input })
|
||||
setInput("")
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen)
|
||||
}
|
||||
|
||||
const handleCopyMessage = useCallback((messageId: string, text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedMessageId(messageId)
|
||||
setTimeout(() => setCopiedMessageId(null), 2000)
|
||||
}, [])
|
||||
|
||||
const handleLikeMessage = useCallback((messageId: string) => {
|
||||
setMessageFeedback((prev) => ({
|
||||
...prev,
|
||||
[messageId]: prev[messageId] === "like" ? null : "like",
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleDislikeMessage = useCallback((messageId: string) => {
|
||||
setMessageFeedback((prev) => ({
|
||||
...prev,
|
||||
[messageId]: prev[messageId] === "dislike" ? null : "dislike",
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleToggleMemories = useCallback((messageId: string) => {
|
||||
setExpandedMemories((prev) => (prev === messageId ? null : messageId))
|
||||
}, [])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
console.log("handleNewChat")
|
||||
const newId = crypto.randomUUID()
|
||||
setCurrentChatId(newId)
|
||||
setMessages([])
|
||||
setInput("")
|
||||
}, [setCurrentChatId, setMessages])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key.toLowerCase() === "t" &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
isChatOpen &&
|
||||
document.activeElement?.tagName !== "INPUT" &&
|
||||
document.activeElement?.tagName !== "TEXTAREA"
|
||||
) {
|
||||
e.preventDefault()
|
||||
handleNewChat()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isChatOpen, handleNewChat])
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!isChatOpen ? (
|
||||
<motion.div
|
||||
key="closed"
|
||||
className={cn(
|
||||
"absolute top-0 right-0 flex items-start justify-start m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<NovaOrb size={24} className="!blur-none z-10" />
|
||||
Chat with Nova
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="open"
|
||||
className={cn(
|
||||
"w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2] relative",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
initial={{ x: "100px", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100px", opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-between pt-4 px-6 pb-3 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.4)]">
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-3 !h-10 border-[#73737333] bg-[#0D121A] cursor-pointer"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={handleNewChat}
|
||||
title="New chat (T)"
|
||||
>
|
||||
<SquarePenIcon className="size-4 text-[#737373]" />
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
T
|
||||
</span>
|
||||
</Button>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 scrollbar-thin">
|
||||
{messages.length === 0 && <ChatEmptyStatePlaceholder />}
|
||||
<div
|
||||
className={cn(
|
||||
messages.length > 0
|
||||
? "flex flex-col space-y-3 min-h-full justify-end"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: Hover detection for message actions
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2 w-full",
|
||||
message.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
onMouseEnter={() =>
|
||||
message.role === "assistant" &&
|
||||
setHoveredMessageId(message.id)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
message.role === "assistant" && setHoveredMessageId(null)
|
||||
}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<UserMessage
|
||||
message={message}
|
||||
copiedMessageId={copiedMessageId}
|
||||
onCopy={handleCopyMessage}
|
||||
/>
|
||||
) : (
|
||||
<AgentMessage
|
||||
message={message}
|
||||
index={index}
|
||||
messagesLength={messages.length}
|
||||
hoveredMessageId={hoveredMessageId}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
expandedMemories={expandedMemories}
|
||||
onCopy={handleCopyMessage}
|
||||
onLike={handleLikeMessage}
|
||||
onDislike={handleDislikeMessage}
|
||||
onToggleMemories={handleToggleMemories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(status === "submitted" || status === "streaming") &&
|
||||
messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="flex gap-2">
|
||||
<SuperLoader label="Thinking..." />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatEmptyStatePlaceholder() {
|
||||
const suggestions = [
|
||||
"Show me all content related to Supermemory.",
|
||||
"Summarize the key ideas from My Gita.",
|
||||
"Which memories connect design and AI?",
|
||||
"What are the main themes across my memories?",
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="relative">
|
||||
<GradientLogo className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
<LogoBgGradient className="" />
|
||||
</div>
|
||||
<div className="gap-3 flex flex-col items-center justify-center">
|
||||
<p>Ask me anything about your memories...</p>
|
||||
<div
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex flex-col gap-2 justify-center items-center",
|
||||
)}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
variant="default"
|
||||
className="rounded-full text-base gap-1 !h-10 border-[#2261CA33] bg-[#0D121A] border w-fit"
|
||||
>
|
||||
<SearchIcon className="size-4 text-[#267BF1]" />
|
||||
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
apps/web/components/chat/input.tsx
Normal file
116
apps/web/components/chat/input.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"use client"
|
||||
|
||||
import { ArrowUpIcon, ChevronUpIcon, SquareIcon } from "lucide-react"
|
||||
import NovaOrb from "../nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onSend: () => void
|
||||
onStop: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
isResponding?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
isResponding = false,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
|
||||
const textarea = e.target
|
||||
textarea.style.height = "auto"
|
||||
|
||||
// Set height based on scrollHeight, with a max of ~96px (4-5 lines)
|
||||
const newHeight = Math.min(textarea.scrollHeight, 96)
|
||||
textarea.style.height = `${newHeight}px`
|
||||
|
||||
setIsMultiline(textarea.scrollHeight > 52)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#01173C] rounded-xl">
|
||||
<div className=" p-3 pr-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<NovaOrb size={24} className="!blur-none z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
Waiting for input...
|
||||
</p>
|
||||
</div>
|
||||
<ChevronUpIcon className="size-4 text-[#525D6E]" />
|
||||
</div>
|
||||
<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",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
96
apps/web/components/chat/message/agent-message.tsx
Normal file
96
apps/web/components/chat/message/agent-message.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client"
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { RelatedMemories } from "./related-memories"
|
||||
import { MessageActions } from "./message-actions"
|
||||
|
||||
interface AgentMessageProps {
|
||||
message: UIMessage
|
||||
index: number
|
||||
messagesLength: number
|
||||
hoveredMessageId: string | null
|
||||
copiedMessageId: string | null
|
||||
messageFeedback: Record<string, "like" | "dislike" | null>
|
||||
expandedMemories: string | 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,
|
||||
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(" ")
|
||||
|
||||
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 === "text") {
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-sm text-white/90"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type === "tool-searchMemories") {
|
||||
if (
|
||||
part.state === "input-available" ||
|
||||
part.state === "input-streaming"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-xs text-white/50 italic"
|
||||
>
|
||||
Searching memories...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
apps/web/components/chat/message/message-actions.tsx
Normal file
80
apps/web/components/chat/message/message-actions.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
interface MessageActionsProps {
|
||||
messageId: string
|
||||
messageText: string
|
||||
isLastMessage: boolean
|
||||
isHovered: boolean
|
||||
copiedMessageId: string | null
|
||||
messageFeedback: Record<string, "like" | "dislike" | null>
|
||||
onCopy: (messageId: string, text: string) => void
|
||||
onLike: (messageId: string) => void
|
||||
onDislike: (messageId: string) => void
|
||||
}
|
||||
|
||||
export function MessageActions({
|
||||
messageId,
|
||||
messageText,
|
||||
isLastMessage,
|
||||
isHovered,
|
||||
copiedMessageId,
|
||||
messageFeedback,
|
||||
onCopy,
|
||||
onLike,
|
||||
onDislike,
|
||||
}: MessageActionsProps) {
|
||||
const shouldShowActions = isHovered || isLastMessage
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 transition-opacity duration-200",
|
||||
shouldShowActions ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(messageId, messageText)}
|
||||
className="p-1.5 hover:bg-white/10 rounded transition-colors"
|
||||
title="Copy message"
|
||||
>
|
||||
{copiedMessageId === messageId ? (
|
||||
<Check className="size-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="size-3.5 text-white/50 hover:text-white/80" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLike(messageId)}
|
||||
className="p-1.5 hover:bg-white/10 rounded transition-colors"
|
||||
title="Like message"
|
||||
>
|
||||
<ThumbsUp
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
messageFeedback[messageId] === "like"
|
||||
? "text-green-400 fill-green-400"
|
||||
: "text-white/50 hover:text-white/80",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDislike(messageId)}
|
||||
className="p-1.5 hover:bg-white/10 rounded transition-colors"
|
||||
title="Dislike message"
|
||||
>
|
||||
<ThumbsDown
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
messageFeedback[messageId] === "dislike"
|
||||
? "text-red-400 fill-red-400"
|
||||
: "text-white/50 hover:text-white/80",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
apps/web/components/chat/message/related-memories.tsx
Normal file
124
apps/web/components/chat/message/related-memories.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { dmSansClassName } from "@/utils/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
|
||||
onToggle: (messageId: string) => void
|
||||
}
|
||||
|
||||
export function RelatedMemories({
|
||||
message,
|
||||
expandedMemories,
|
||||
onToggle,
|
||||
}: RelatedMemoriesProps) {
|
||||
const memoryResults: MemoryResult[] = []
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
if (memoryResults.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isExpanded = expandedMemories === message.id
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-white/50 hover:text-white/70 transition-colors text-sm"
|
||||
onClick={() => onToggle(message.id)}
|
||||
>
|
||||
Related Memories
|
||||
{isExpanded ? (
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 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 rounded-xl">
|
||||
{result.title && (
|
||||
<div className="text-xs text-white/60 line-clamp-2">
|
||||
{result.title}
|
||||
</div>
|
||||
)}
|
||||
{result.content && (
|
||||
<div className="text-xs text-white/60 line-clamp-2">
|
||||
{result.content}
|
||||
</div>
|
||||
)}
|
||||
{result.url && (
|
||||
<div className="text-xs text-blue-400 mt-1 truncate">
|
||||
{result.url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.score && (
|
||||
<div className="text-xs text-white/50 mt-1 p-1 text-center">
|
||||
Relevance Score: {(result.score * 100).toFixed(1)}%
|
||||
</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 border border-white/10",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
key={result.documentId || idx}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
apps/web/components/chat/message/user-message.tsx
Normal file
41
apps/web/components/chat/message/user-message.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client"
|
||||
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
|
||||
interface UserMessageProps {
|
||||
message: UIMessage
|
||||
copiedMessageId: string | null
|
||||
onCopy: (messageId: string, text: string) => void
|
||||
}
|
||||
|
||||
export function UserMessage({
|
||||
message,
|
||||
copiedMessageId,
|
||||
onCopy,
|
||||
}: UserMessageProps) {
|
||||
const text = message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end w-full">
|
||||
<div className="bg-[#293952]/60 rounded-lg p-3 px-[14px] max-w-[80%]">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(message.id, text)}
|
||||
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
|
||||
title="Copy message"
|
||||
>
|
||||
{copiedMessageId === message.id ? (
|
||||
<Check className="size-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="size-3.5 text-white/50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
apps/web/components/chat/model-selector.tsx
Normal file
101
apps/web/components/chat/model-selector.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { models, type ModelId, modelNames } from "@/lib/models"
|
||||
|
||||
interface ChatModelSelectorProps {
|
||||
selectedModel?: ModelId
|
||||
onModelChange?: (model: ModelId) => void
|
||||
}
|
||||
|
||||
export default function ChatModelSelector({
|
||||
selectedModel: selectedModelProp,
|
||||
onModelChange,
|
||||
}: ChatModelSelectorProps = {}) {
|
||||
const [internalModel, setInternalModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const selectedModel = selectedModelProp ?? internalModel
|
||||
const currentModelData = modelNames[selectedModel]
|
||||
|
||||
const handleModelSelect = (modelId: ModelId) => {
|
||||
if (onModelChange) {
|
||||
onModelChange(modelId)
|
||||
} else {
|
||||
setInternalModel(modelId)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full text-base gap-1 !h-10 border-[#73737333] bg-[#0D121A]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#737373]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setIsOpen(false)}
|
||||
aria-label="Close model selector"
|
||||
/>
|
||||
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
|
||||
selectedModel === model.id
|
||||
? "bg-[#293952]/60"
|
||||
: "hover:bg-[#293952]/40",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{modelData.name}{" "}
|
||||
<span className="text-[#737373]">
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[#737373] truncate w-full">
|
||||
{model.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
HistoryIcon,
|
||||
PanelRightCloseIcon,
|
||||
SendIcon,
|
||||
SquarePenIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/utils/fonts"
|
||||
|
||||
export function ChatSidebar() {
|
||||
const [message, setMessage] = useState("")
|
||||
const [isChatOpen, setIsChatOpen] = useState(true)
|
||||
|
||||
// Static placeholder messages for UI display
|
||||
const messages = [
|
||||
//{
|
||||
// message: "Sample memory content",
|
||||
// type: "memory" as const,
|
||||
// memories: [
|
||||
// {
|
||||
// url: "https://example.com",
|
||||
// title: "Example Memory",
|
||||
// description: "This is a sample memory description for UI display purposes.",
|
||||
// fullContent: "This is a sample memory description for UI display purposes.",
|
||||
// },
|
||||
// ],
|
||||
//},
|
||||
]
|
||||
|
||||
const handleSend = () => {
|
||||
setMessage("")
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!isChatOpen ? (
|
||||
<motion.div
|
||||
key="closed"
|
||||
className={cn(
|
||||
"absolute top-0 right-0 flex items-start justify-start m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border-[1px] border-[#17181A] text-white cursor-pointer"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<NovaOrb size={24} className="!blur-none z-10" />
|
||||
Chat with Nova
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="open"
|
||||
className={cn(
|
||||
"w-[450px] h-[calc(100vh-110px)] bg-[#0A0E14] backdrop-blur-md flex flex-col rounded-2xl m-4 border border-[#17181AB2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
initial={{ x: "100px", opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: "100px", opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 px-6">
|
||||
<p>Chat Title</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 !h-10 border-[#73737333] bg-[#0D121A]"
|
||||
>
|
||||
<SquarePenIcon className="size-4 text-[#737373]" />
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
T
|
||||
</span>
|
||||
</Button>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className="flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer"
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={`message-${i}-${msg.message}`}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
{msg.type === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<NovaOrb size={30} className="!blur-none" />
|
||||
<span className="text-sm">{msg.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center w-[30px] h-full",
|
||||
i !== 0 && "",
|
||||
)}
|
||||
>
|
||||
{i === 0 && (
|
||||
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
|
||||
)}
|
||||
<div className="w-[1px] flex-1 bg-[#293952]/40" />
|
||||
</div>
|
||||
{msg.type === "memory" && (
|
||||
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{msg.memories?.map((memory) => (
|
||||
<div
|
||||
key={memory.url + memory.title}
|
||||
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
|
||||
>
|
||||
{memory.title && (
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
{memory.title}
|
||||
</h3>
|
||||
)}
|
||||
{memory.url && (
|
||||
<a
|
||||
href={memory.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{memory.url}
|
||||
</a>
|
||||
)}
|
||||
{memory.description && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
{memory.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<form
|
||||
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (message.trim()) {
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Chat with your Supermemory"
|
||||
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
|
||||
/>
|
||||
<div className="flex justify-end absolute bottom-3 right-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!message.trim()}
|
||||
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
|
||||
size="icon"
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ export function MemoriesGrid() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto custom-scrollbar">
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
<Masonry
|
||||
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}`}
|
||||
items={documents}
|
||||
|
|
@ -237,7 +237,11 @@ const DocumentCard = memo(
|
|||
? "pb-1"
|
||||
: "",
|
||||
)}
|
||||
style={{ width }}
|
||||
style={{
|
||||
width,
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<ContentPreview document={document} />
|
||||
{!(
|
||||
|
|
@ -277,7 +281,8 @@ const DocumentCard = memo(
|
|||
}}
|
||||
>
|
||||
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
|
||||
{document.memoryEntries.length} {document.memoryEntries.length === 1 ? "memory" : "memories"}
|
||||
{document.memoryEntries.length}{" "}
|
||||
{document.memoryEntries.length === 1 ? "memory" : "memories"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,98 +1,98 @@
|
|||
'use client';
|
||||
"use client"
|
||||
|
||||
import { motion, useReducedMotion, Variants } from 'motion/react';
|
||||
import { motion, useReducedMotion, Variants } from "motion/react"
|
||||
|
||||
type NovaPathLoaderProps = {
|
||||
size?: number; // px
|
||||
colorClassName?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
size?: number // px
|
||||
colorClassName?: string
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// full SVG path data from nova-2d-anim.svg
|
||||
const PATH_RIGHT =
|
||||
'M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z';
|
||||
"M3.03472 6.05861L6.8539 9.91021H1.96777V11.9737H8.3006V17.4781H10.3467V11.5057C10.3467 10.8713 10.0963 10.2621 9.65119 9.81327L4.48145 4.59961L3.03472 6.05861Z"
|
||||
|
||||
const PATH_LEFT =
|
||||
'M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z';
|
||||
"M12.6994 9.02793V3.52344H10.6533V9.49591C10.6533 10.1302 10.9037 10.7395 11.3488 11.1883L16.5197 16.4032L17.9665 14.9441L14.1473 11.0926H19.0334V9.02914L12.6994 9.02793Z"
|
||||
|
||||
// animation for stroke draw
|
||||
const strokeVariants: Variants = {
|
||||
hidden: { pathLength: 0, opacity: 0.2 },
|
||||
visible: (i: number) => ({
|
||||
pathLength: [0, 1],
|
||||
opacity: [0.2, 1],
|
||||
transition: {
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
ease: 'easeInOut',
|
||||
delay: i * 0.18,
|
||||
},
|
||||
}),
|
||||
static: { pathLength: 1, opacity: 0.7 },
|
||||
};
|
||||
|
||||
export function SuperLoader({
|
||||
size = 42,
|
||||
colorClassName = 'text-sky-400',
|
||||
label = 'Loading...',
|
||||
className = '',
|
||||
}: NovaPathLoaderProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
const animateVariant = prefersReducedMotion ? 'static' : 'visible';
|
||||
|
||||
return (
|
||||
<div
|
||||
role='status'
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-col items-center gap-2 ${className}`}
|
||||
style={{ width: size + 10 }}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 21 21'
|
||||
width={size}
|
||||
height={size}
|
||||
className={`shrink-0 ${colorClassName}`}
|
||||
>
|
||||
{/* Right path */}
|
||||
<motion.path
|
||||
d={PATH_RIGHT}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
initial='hidden'
|
||||
animate={animateVariant}
|
||||
variants={strokeVariants}
|
||||
custom={0}
|
||||
/>
|
||||
|
||||
{/* Left path */}
|
||||
<motion.path
|
||||
d={PATH_LEFT}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
initial='hidden'
|
||||
animate={animateVariant}
|
||||
variants={strokeVariants}
|
||||
custom={1}
|
||||
/>
|
||||
</motion.svg>
|
||||
|
||||
<span
|
||||
className='text-xs font-medium text-slate-500'
|
||||
style={{ fontSize: size * 0.25 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
hidden: { pathLength: 0, opacity: 0.2 },
|
||||
visible: (i: number) => ({
|
||||
pathLength: [0, 1],
|
||||
opacity: [0.2, 1],
|
||||
transition: {
|
||||
duration: 0.9,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: "reverse",
|
||||
ease: "easeInOut",
|
||||
delay: i * 0.18,
|
||||
},
|
||||
}),
|
||||
static: { pathLength: 1, opacity: 0.7 },
|
||||
}
|
||||
|
||||
export function SuperLoader({
|
||||
size = 42,
|
||||
colorClassName = "text-sky-400",
|
||||
label = "Loading...",
|
||||
className = "",
|
||||
}: NovaPathLoaderProps) {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const animateVariant = prefersReducedMotion ? "static" : "visible"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-col items-center gap-2 ${className}`}
|
||||
style={{ width: size + 10 }}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 21 21"
|
||||
width={size}
|
||||
height={size}
|
||||
className={`shrink-0 ${colorClassName}`}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
{/* Right path */}
|
||||
<motion.path
|
||||
d={PATH_RIGHT}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial="hidden"
|
||||
animate={animateVariant}
|
||||
variants={strokeVariants}
|
||||
custom={0}
|
||||
/>
|
||||
|
||||
{/* Left path */}
|
||||
<motion.path
|
||||
d={PATH_LEFT}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial="hidden"
|
||||
animate={animateVariant}
|
||||
variants={strokeVariants}
|
||||
custom={1}
|
||||
/>
|
||||
</motion.svg>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium text-slate-500"
|
||||
style={{ fontSize: size * 0.25 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -709,7 +709,7 @@ export function ChatMessages() {
|
|||
className="text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{modelNames[selectedModel]}
|
||||
{/*{modelNames[selectedModel]}*/}
|
||||
</span>
|
||||
</div>
|
||||
{status === "streaming" || status === "submitted" ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
|
|
@ -27,8 +27,8 @@
|
|||
padding: 0 !important;
|
||||
font-family: "DM Sans", sans-serif !important;
|
||||
font-size: 10px !important;
|
||||
--tweet-header-font-size: 10px !important;
|
||||
--tweet-header-line-height: 1.25rem !important;
|
||||
--tweet-header-font-size: 10px !important;
|
||||
--tweet-header-line-height: 1.25rem !important;
|
||||
}
|
||||
|
||||
.tweet-body-module__ZNRZja__root > * {
|
||||
|
|
@ -53,4 +53,4 @@
|
|||
.font-dm-sans {
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 135%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ export const models = [
|
|||
|
||||
export type ModelId = (typeof models)[number]["id"]
|
||||
|
||||
export const modelNames: Record<ModelId, string> = {
|
||||
"gpt-5": "GPT 5",
|
||||
"claude-sonnet-4.5": "Claude Sonnet 4.5",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
export const modelNames: Record<ModelId, { name: string; version: string }> = {
|
||||
"gpt-5": { name: "GPT", version: "5" },
|
||||
"claude-sonnet-4.5": { name: "Claude", version: "4.5" },
|
||||
"gemini-2.5-pro": { name: "Gemini", version: "2.5 Pro" },
|
||||
}
|
||||
|
||||
interface ModelIconProps {
|
||||
|
|
|
|||
|
|
@ -49,3 +49,267 @@ export const LogoFull = ({
|
|||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GradientLogo = ({ className = ""} : { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="65"
|
||||
height="53"
|
||||
viewBox="0 0 65 53"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<title>Gradient supermemory logo</title>
|
||||
<g clip-path="url(#clip0_503_43779)">
|
||||
<path
|
||||
d="M64.1607 20.7042H40.3439V0.00268555H32.6488V22.4642C32.6488 24.8499 33.5906 27.141 35.2645 28.8291L54.7116 48.4414L60.1526 42.9542L45.7893 28.4691H64.1651V20.7088L64.1607 20.7042Z"
|
||||
fill="url(#paint0_linear_503_43779)"
|
||||
/>
|
||||
<path
|
||||
d="M3.9992 9.536L18.3626 24.0213H-0.0134277V31.7816H23.8034V52.483H31.4984V30.0216C31.4984 27.6358 30.5567 25.3446 28.8827 23.6567L9.44012 4.0489L3.9992 9.536Z"
|
||||
fill="url(#paint1_linear_503_43779)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_503_43779"
|
||||
x1="3.272"
|
||||
y1="-0.0520011"
|
||||
x2="122.975"
|
||||
y2="9.51098"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#369BFD" />
|
||||
<stop offset="0.41" stop-color="#36FDFD" />
|
||||
<stop offset="0.79" stop-color="#36FDB5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_503_43779"
|
||||
x1="3.272"
|
||||
y1="-0.0520011"
|
||||
x2="122.975"
|
||||
y2="9.51098"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#369BFD" />
|
||||
<stop offset="0.41" stop-color="#36FDFD" />
|
||||
<stop offset="0.79" stop-color="#36FDB5" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_503_43779">
|
||||
<rect
|
||||
width="64.732"
|
||||
height="52.4854"
|
||||
fill="white"
|
||||
transform="translate(-0.0136719)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const LogoBgGradient = ({ className = "" }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="182"
|
||||
height="184"
|
||||
viewBox="0 0 182 184"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<title>Logo background gradient</title>
|
||||
<g opacity="0.7">
|
||||
<g filter="url(#filter0_f_503_43757)">
|
||||
<ellipse
|
||||
cx="14.2835"
|
||||
cy="24.7691"
|
||||
rx="14.2835"
|
||||
ry="24.7691"
|
||||
transform="matrix(1 0 0 -1 86.3853 115.758)"
|
||||
fill="#1410FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_503_43757)">
|
||||
<ellipse
|
||||
cx="80.5032"
|
||||
cy="92.8882"
|
||||
rx="14.2835"
|
||||
ry="24.7691"
|
||||
transform="rotate(-180 80.5032 92.8882)"
|
||||
fill="#1410FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter2_f_503_43757)">
|
||||
<ellipse
|
||||
cx="23.4137"
|
||||
cy="11.8923"
|
||||
rx="23.4137"
|
||||
ry="11.8923"
|
||||
transform="matrix(0.979973 -0.199127 -0.12394 -0.99229 69.9859 107.485)"
|
||||
fill="#0091FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter3_f_503_43757)">
|
||||
<ellipse
|
||||
cx="9.9778"
|
||||
cy="7.53189"
|
||||
rx="9.9778"
|
||||
ry="7.53189"
|
||||
transform="matrix(0.829871 -0.557955 -0.381948 -0.924183 98.8062 105.408)"
|
||||
fill="#0099FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter4_f_503_43757)">
|
||||
<ellipse
|
||||
cx="9.9778"
|
||||
cy="7.53189"
|
||||
rx="9.9778"
|
||||
ry="7.53189"
|
||||
transform="matrix(-0.829871 -0.557955 0.381948 -0.924184 82.4371 105.912)"
|
||||
fill="#0099FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter="url(#filter5_f_503_43757)">
|
||||
<ellipse
|
||||
cx="5.38795"
|
||||
cy="10.704"
|
||||
rx="5.38795"
|
||||
ry="10.704"
|
||||
transform="matrix(-3.03601e-08 -1 -1 5.00629e-08 99.5963 84.5005)"
|
||||
fill="#47A8FD"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_f_503_43757"
|
||||
x="20.1657"
|
||||
y="9.15527e-05"
|
||||
width="161.006"
|
||||
height="181.977"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="33.1098"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter1_f_503_43757"
|
||||
x="3.05176e-05"
|
||||
y="1.89951"
|
||||
width="161.006"
|
||||
height="181.977"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="33.1098"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter2_f_503_43757"
|
||||
x="18.7991"
|
||||
y="28.6653"
|
||||
width="145.315"
|
||||
height="124.713"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="24.8324"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter3_f_503_43757"
|
||||
x="54.0543"
|
||||
y="42.579"
|
||||
width="100.311"
|
||||
height="100.602"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="20.6936"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter4_f_503_43757"
|
||||
x="26.8781"
|
||||
y="43.0827"
|
||||
width="100.311"
|
||||
height="100.602"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="20.6936"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
<filter
|
||||
id="filter5_f_503_43757"
|
||||
x="28.5235"
|
||||
y="24.0599"
|
||||
width="120.737"
|
||||
height="110.105"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
/>
|
||||
<feGaussianBlur
|
||||
stdDeviation="24.8324"
|
||||
result="effect1_foregroundBlur_503_43757"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue