mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix: merge conflicts
This commit is contained in:
commit
4ca0f593a5
21 changed files with 1649 additions and 98 deletions
|
|
@ -172,7 +172,7 @@ export class SupermemoryClient {
|
|||
response.searchResults = {
|
||||
results: (result.searchResults.results as SDKResult[]).map((r) => ({
|
||||
id: r.id,
|
||||
memory: limitByChars(r.content || r.context || ""),
|
||||
memory: limitByChars(r.content || r.memory || r.context || ""),
|
||||
similarity: r.similarity,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { GraphDialog } from "@/components/graph-dialog"
|
||||
import { Header } from "@/components/header"
|
||||
import { AddMemoryView } from "@/components/views/add-memory"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export default function NavigationLayout({
|
||||
|
|
@ -11,6 +13,16 @@ export default function NavigationLayout({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
|
||||
|
||||
useEffect(() => {
|
||||
if (flagEnabled && !pathname.includes("/new")) {
|
||||
router.replace("/new")
|
||||
}
|
||||
}, [flagEnabled, router, pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
|
|
|||
|
|
@ -37,6 +37,70 @@ function isPrivateHost(hostname: string): boolean {
|
|||
return privateIpPatterns.some((pattern) => pattern.test(hostname))
|
||||
}
|
||||
|
||||
// File extensions that are not HTML and can't be scraped for OG data
|
||||
const NON_HTML_EXTENSIONS = [
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".tar",
|
||||
".gz",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".webm",
|
||||
".wav",
|
||||
".ogg",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".ico",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".exe",
|
||||
".dmg",
|
||||
".iso",
|
||||
".bin",
|
||||
]
|
||||
|
||||
function isNonHtmlUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname.toLowerCase()
|
||||
return NON_HTML_EXTENSIONS.some((ext) => pathname.endsWith(ext))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function extractImageUrl(image: unknown): string | undefined {
|
||||
if (!image) return undefined
|
||||
|
||||
if (typeof image === "string") {
|
||||
return image
|
||||
}
|
||||
|
||||
if (Array.isArray(image) && image.length > 0) {
|
||||
const first = image[0]
|
||||
if (first && typeof first === "object" && "url" in first) {
|
||||
return String(first.url)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function extractMetaTag(html: string, patterns: RegExp[]): string {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern)
|
||||
|
|
@ -101,6 +165,19 @@ export async function GET(request: Request) {
|
|||
)
|
||||
}
|
||||
|
||||
// Skip OG scraping for non-HTML files (PDFs, images, etc.)
|
||||
if (isNonHtmlUrl(trimmedUrl)) {
|
||||
return Response.json(
|
||||
{ title: "", description: "" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control":
|
||||
"public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ import { AddDocumentModal } from "@/components/new/add-document"
|
|||
import { MCPModal } from "@/components/new/mcp-modal"
|
||||
import { DocumentModal } from "@/components/new/document-modal"
|
||||
import { DocumentsCommandPalette } from "@/components/new/documents-command-palette"
|
||||
import { FullscreenNoteModal } from "@/components/new/fullscreen-note-modal"
|
||||
import type { HighlightItem } from "@/components/new/highlights-card"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraftReset } from "@/stores/quick-note-draft"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
|
||||
|
|
@ -31,6 +36,56 @@ export default function NewPage() {
|
|||
useState<DocumentWithMemories | null>(null)
|
||||
const [isDocumentModalOpen, setIsDocumentModalOpen] = useState(false)
|
||||
|
||||
const [isFullScreenNoteOpen, setIsFullScreenNoteOpen] = useState(false)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [searchPrefill, setSearchPrefill] = useState("")
|
||||
|
||||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
|
||||
const { noteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
resetDraft()
|
||||
setIsFullScreenNoteOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch space highlights (highlights + suggested questions)
|
||||
type SpaceHighlightsResponse = {
|
||||
highlights: HighlightItem[]
|
||||
questions: string[]
|
||||
generatedAt: string
|
||||
}
|
||||
const { data: highlightsData, isLoading: isLoadingHighlights } =
|
||||
useQuery<SpaceHighlightsResponse>({
|
||||
queryKey: ["space-highlights", selectedProject],
|
||||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
spaceId: selectedProject || "sm_project_default",
|
||||
highlightsCount: 3,
|
||||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch space highlights")
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 4 * 60 * 60 * 1000, // 4 hours (matches backend cache)
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
useHotkeys("c", () => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setIsAddDocumentOpen(true)
|
||||
|
|
@ -46,6 +101,42 @@ export default function NewPage() {
|
|||
setIsDocumentModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleQuickNoteSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
noteMutation.mutate({ content, project: selectedProject })
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation],
|
||||
)
|
||||
|
||||
const handleFullScreenSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
noteMutation.mutate({ content, project: selectedProject })
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation],
|
||||
)
|
||||
|
||||
const handleMaximize = useCallback(
|
||||
(content: string) => {
|
||||
setFullscreenInitialContent(content)
|
||||
setIsFullScreenNoteOpen(true)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleHighlightsChat = useCallback((seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
setIsChatOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleHighlightsShowRelated = useCallback((query: string) => {
|
||||
setSearchPrefill(query)
|
||||
setIsSearchOpen(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div className="bg-black min-h-screen">
|
||||
|
|
@ -69,10 +160,21 @@ export default function NewPage() {
|
|||
key={`main-container-${isChatOpen}`}
|
||||
className="z-10 flex flex-col md:flex-row relative"
|
||||
>
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0">
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={isChatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
|
|
@ -80,13 +182,22 @@ export default function NewPage() {
|
|||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && (
|
||||
<ChatSidebar isChatOpen={isChatOpen} setIsChatOpen={setIsChatOpen} />
|
||||
<ChatSidebar
|
||||
isChatOpen={isChatOpen}
|
||||
setIsChatOpen={setIsChatOpen}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
|
|
@ -99,15 +210,26 @@ export default function NewPage() {
|
|||
/>
|
||||
<DocumentsCommandPalette
|
||||
open={isSearchOpen}
|
||||
onOpenChange={setIsSearchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSearchOpen(open)
|
||||
if (!open) setSearchPrefill("")
|
||||
}}
|
||||
projectId={selectedProject}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
initialSearch={searchPrefill}
|
||||
/>
|
||||
<DocumentModal
|
||||
document={selectedDocument}
|
||||
isOpen={isDocumentModalOpen}
|
||||
onClose={() => setIsDocumentModalOpen(false)}
|
||||
/>
|
||||
<FullscreenNoteModal
|
||||
isOpen={isFullScreenNoteOpen}
|
||||
onClose={() => setIsFullScreenNoteOpen(false)}
|
||||
initialContent={fullscreenInitialContent}
|
||||
onSave={handleFullScreenSave}
|
||||
isSaving={noteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</HotkeysProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { ScrollArea } from "@ui/components/scroll-area"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -98,7 +99,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
|||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
router.push(`/chat/${newId}`)
|
||||
setIsDialogOpen(false)
|
||||
|
|
@ -129,7 +130,7 @@ export function Header({ onAddMemory }: { onAddMemory?: () => void }) {
|
|||
>
|
||||
{getCurrentChat()?.title && pathname.includes("/chat") ? (
|
||||
<div className="flex items-center gap-2 md:gap-4 min-w-0 max-w-[200px] md:max-w-md">
|
||||
<Logo className="h-6 block text-foreground flex-shrink-0" />
|
||||
<Logo className="h-6 block text-foreground shrink-0" />
|
||||
<span className="truncate text-sm md:text-base">
|
||||
{getCurrentChat()?.title}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -7,19 +7,33 @@ import { DefaultChatTransport } from "ai"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { ScrollArea } from "@ui/components/scroll-area"
|
||||
import {
|
||||
Check,
|
||||
ChevronDownIcon,
|
||||
HistoryIcon,
|
||||
PanelRightCloseIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
SquarePenIcon,
|
||||
Trash2,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import ChatInput from "./input"
|
||||
import ChatModelSelector from "./model-selector"
|
||||
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
|
||||
import { useProject, usePersistentChat } from "@/stores"
|
||||
import { areUIMessageArraysEqual } from "@/stores/chat"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { SuperLoader } from "../../superloader"
|
||||
import { UserMessage } from "./message/user-message"
|
||||
|
|
@ -27,19 +41,22 @@ import { AgentMessage } from "./message/agent-message"
|
|||
import { ChainOfThought } from "./input/chain-of-thought"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
const DEFAULT_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?",
|
||||
]
|
||||
|
||||
function ChatEmptyStatePlaceholder({
|
||||
onSuggestionClick,
|
||||
suggestions = DEFAULT_SUGGESTIONS,
|
||||
}: {
|
||||
onSuggestionClick: (suggestion: string) => void
|
||||
suggestions?: string[]
|
||||
}) {
|
||||
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
|
||||
id="chat-empty-state"
|
||||
|
|
@ -61,11 +78,13 @@ function ChatEmptyStatePlaceholder({
|
|||
<Button
|
||||
key={suggestion}
|
||||
variant="default"
|
||||
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
|
||||
className="rounded-full text-base gap-1 h-10! border-[#2261CA33] bg-[#041127] border w-fit max-w-[400px] py-[4px] pl-[8px] pr-[12px] hover:bg-[#0A1A3A] hover:[&_span]:text-white hover:[&_svg]:text-white transition-colors cursor-pointer"
|
||||
onClick={() => onSuggestionClick(suggestion)}
|
||||
>
|
||||
<SearchIcon className="size-4 text-[#267BF1]" />
|
||||
<span className="text-[#267BF1] text-[12px]">{suggestion}</span>
|
||||
<SearchIcon className="size-4 text-[#267BF1] shrink-0" />
|
||||
<span className="text-[#267BF1] text-[12px] truncate">
|
||||
{suggestion}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -77,9 +96,15 @@ function ChatEmptyStatePlaceholder({
|
|||
export function ChatSidebar({
|
||||
isChatOpen,
|
||||
setIsChatOpen,
|
||||
queuedMessage,
|
||||
onConsumeQueuedMessage,
|
||||
emptyStateSuggestions,
|
||||
}: {
|
||||
isChatOpen: boolean
|
||||
setIsChatOpen: (open: boolean) => void
|
||||
queuedMessage?: string | null
|
||||
onConsumeQueuedMessage?: () => void
|
||||
emptyStateSuggestions?: string[]
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [input, setInput] = useState("")
|
||||
|
|
@ -99,10 +124,34 @@ export function ChatSidebar({
|
|||
const [isInputExpanded, setIsInputExpanded] = useState(false)
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
|
||||
const [heightOffset, setHeightOffset] = useState(95)
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||
const [threads, setThreads] = useState<
|
||||
Array<{ id: string; title: string; createdAt: string; updatedAt: string }>
|
||||
>([])
|
||||
const [isLoadingThreads, setIsLoadingThreads] = useState(false)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { setCurrentChatId } = usePersistentChat()
|
||||
const {
|
||||
currentChatId,
|
||||
setCurrentChatId,
|
||||
setConversation,
|
||||
getCurrentConversation,
|
||||
} = usePersistentChat()
|
||||
const lastSavedMessagesRef = useRef<typeof messages | null>(null)
|
||||
const lastSavedActiveIdRef = useRef<string | null>(null)
|
||||
const lastLoadedChatIdRef = useRef<string | null>(null)
|
||||
const lastLoadedMessagesRef = useRef<typeof messages | null>(null)
|
||||
|
||||
// Initialize chat ID if none exists
|
||||
useEffect(() => {
|
||||
if (!currentChatId) {
|
||||
setCurrentChatId(generateId())
|
||||
}
|
||||
}, [currentChatId, setCurrentChatId])
|
||||
|
||||
// Adjust chat height based on scroll position (desktop only)
|
||||
useEffect(() => {
|
||||
|
|
@ -123,6 +172,7 @@ export function ChatSidebar({
|
|||
}, [isMobile])
|
||||
|
||||
const { messages, sendMessage, status, setMessages, stop } = useChat({
|
||||
id: currentChatId ?? undefined,
|
||||
transport: new DefaultChatTransport({
|
||||
api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`,
|
||||
credentials: "include",
|
||||
|
|
@ -130,6 +180,7 @@ export function ChatSidebar({
|
|||
metadata: {
|
||||
projectId: selectedProject,
|
||||
model: selectedModel,
|
||||
chatId: currentChatId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -144,6 +195,59 @@ export function ChatSidebar({
|
|||
},
|
||||
})
|
||||
|
||||
// Restore messages from store when currentChatId changes
|
||||
useEffect(() => {
|
||||
if (currentChatId !== lastLoadedChatIdRef.current) {
|
||||
lastLoadedMessagesRef.current = null
|
||||
lastSavedMessagesRef.current = null
|
||||
}
|
||||
|
||||
if (currentChatId === lastLoadedChatIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const msgs = getCurrentConversation()
|
||||
|
||||
if (msgs && msgs.length > 0) {
|
||||
const currentMessages = lastLoadedMessagesRef.current
|
||||
if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) {
|
||||
lastLoadedMessagesRef.current = msgs
|
||||
setMessages(msgs)
|
||||
}
|
||||
} else if (!currentChatId) {
|
||||
if (
|
||||
lastLoadedMessagesRef.current &&
|
||||
lastLoadedMessagesRef.current.length > 0
|
||||
) {
|
||||
lastLoadedMessagesRef.current = []
|
||||
setMessages([])
|
||||
}
|
||||
}
|
||||
|
||||
lastLoadedChatIdRef.current = currentChatId
|
||||
}, [currentChatId, getCurrentConversation, setMessages])
|
||||
|
||||
// Persist messages to store whenever they change
|
||||
useEffect(() => {
|
||||
const activeId = currentChatId
|
||||
if (!activeId || messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeId !== lastSavedActiveIdRef.current) {
|
||||
lastSavedMessagesRef.current = null
|
||||
lastSavedActiveIdRef.current = activeId
|
||||
}
|
||||
|
||||
const lastSaved = lastSavedMessagesRef.current
|
||||
if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSavedMessagesRef.current = messages
|
||||
setConversation(activeId, messages)
|
||||
}, [messages, currentChatId, setConversation])
|
||||
|
||||
// Generate follow-up questions after assistant messages are complete
|
||||
useEffect(() => {
|
||||
const generateFollowUps = async () => {
|
||||
|
|
@ -300,12 +404,92 @@ export function ChatSidebar({
|
|||
|
||||
const handleNewChat = useCallback(() => {
|
||||
analytics.newChatCreated()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setMessages([])
|
||||
setInput("")
|
||||
}, [setCurrentChatId, setMessages])
|
||||
|
||||
const fetchThreads = useCallback(async () => {
|
||||
setIsLoadingThreads(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${selectedProject}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setThreads(data.threads || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch threads:", error)
|
||||
} finally {
|
||||
setIsLoadingThreads(false)
|
||||
}
|
||||
}, [selectedProject])
|
||||
|
||||
const loadThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCurrentChatId(threadId)
|
||||
// Convert API messages to UIMessage format
|
||||
const uiMessages = data.messages.map(
|
||||
(m: {
|
||||
id: string
|
||||
role: string
|
||||
parts: unknown
|
||||
createdAt: string
|
||||
}) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
parts: m.parts || [],
|
||||
createdAt: new Date(m.createdAt),
|
||||
}),
|
||||
)
|
||||
setMessages(uiMessages)
|
||||
setConversation(threadId, uiMessages) // persist messages to store
|
||||
setIsHistoryOpen(false)
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
},
|
||||
[setCurrentChatId, setMessages, setConversation],
|
||||
)
|
||||
|
||||
const deleteThread = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
setThreads((prev) => prev.filter((t) => t.id !== threadId))
|
||||
if (currentChatId === threadId) {
|
||||
handleNewChat()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete thread:", error)
|
||||
} finally {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
},
|
||||
[currentChatId, handleNewChat],
|
||||
)
|
||||
|
||||
const formatRelativeTime = (isoString: string): string => {
|
||||
return formatDistanceToNow(new Date(isoString), { addSuffix: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
|
|
@ -332,6 +516,19 @@ export function ChatSidebar({
|
|||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isChatOpen, handleNewChat])
|
||||
|
||||
// Send queued message when chat opens
|
||||
useEffect(() => {
|
||||
if (
|
||||
isChatOpen &&
|
||||
queuedMessage &&
|
||||
status !== "submitted" &&
|
||||
status !== "streaming"
|
||||
) {
|
||||
sendMessage({ text: queuedMessage })
|
||||
onConsumeQueuedMessage?.()
|
||||
}
|
||||
}, [isChatOpen, queuedMessage, status, sendMessage, onConsumeQueuedMessage])
|
||||
|
||||
// Scroll to bottom when a new user message is added
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
|
@ -384,7 +581,7 @@ export function ChatSidebar({
|
|||
"flex items-start justify-start",
|
||||
isMobile
|
||||
? "fixed bottom-5 right-0 left-0 z-50 justify-center items-center"
|
||||
: "absolute top-0 right-0 m-4",
|
||||
: "absolute top-[-10px] right-0 m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
layoutId="chat-toggle-button"
|
||||
|
|
@ -453,15 +650,127 @@ export function ChatSidebar({
|
|||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isMobile && (
|
||||
<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",
|
||||
<Dialog
|
||||
open={isHistoryOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsHistoryOpen(open)
|
||||
if (open) {
|
||||
fetchThreads()
|
||||
analytics.chatHistoryViewed?.()
|
||||
} else {
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg bg-[#0A0E14] border-[#17181AB2] text-white">
|
||||
<DialogHeader className="pb-4 border-b border-[#17181AB2]">
|
||||
<DialogTitle>Chat History</DialogTitle>
|
||||
<DialogDescription className="text-[#737373]">
|
||||
Project: {selectedProject}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-96">
|
||||
{isLoadingThreads ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SuperLoader label="Loading..." />
|
||||
</div>
|
||||
) : threads.length === 0 ? (
|
||||
<div className="text-sm text-[#737373] text-center py-8">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
const isActive = thread.id === currentChatId
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => loadThread(thread.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md px-3 py-2 w-full text-left transition-colors",
|
||||
isActive
|
||||
? "bg-[#267BF1]/10"
|
||||
: "hover:bg-[#17181A]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{thread.title || "Untitled Chat"}
|
||||
</div>
|
||||
<div className="text-xs text-[#737373]">
|
||||
{formatRelativeTime(thread.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{confirmingDeleteId === thread.id ? (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteThread(thread.id)
|
||||
}}
|
||||
className="bg-red-500 text-white hover:bg-red-600 h-7 w-7"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(null)
|
||||
}}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<XIcon className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setConfirmingDeleteId(thread.id)
|
||||
}}
|
||||
className="h-7 w-7 ml-2"
|
||||
>
|
||||
<Trash2 className="size-3 text-[#737373]" />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-dashed border-[#73737333] bg-transparent hover:bg-[#17181A]"
|
||||
onClick={() => {
|
||||
handleNewChat()
|
||||
setIsHistoryOpen(false)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1" /> New Conversation
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Button
|
||||
variant="headers"
|
||||
|
|
@ -526,6 +835,7 @@ export function ChatSidebar({
|
|||
onSuggestionClick={(suggestion) => {
|
||||
sendMessage({ text: suggestion })
|
||||
}}
|
||||
suggestions={emptyStateSuggestions}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export function AgentMessage({
|
|||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-xs text-white/50 italic"
|
||||
className="text-xs text-white italic"
|
||||
>
|
||||
Searching memories...
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,119 @@
|
|||
|
||||
import { Suspense } from "react"
|
||||
import type { Tweet } from "react-tweet/api"
|
||||
import {
|
||||
TweetContainer,
|
||||
TweetHeader,
|
||||
TweetBody,
|
||||
TweetMedia,
|
||||
enrichTweet,
|
||||
TweetSkeleton,
|
||||
} from "react-tweet"
|
||||
import { TweetBody, enrichTweet, TweetSkeleton } from "react-tweet"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { PlayCircle } from "lucide-react"
|
||||
|
||||
function VerifiedBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 22 22"
|
||||
aria-label="Verified account"
|
||||
className={cn("size-3", className)}
|
||||
>
|
||||
<title>Verified</title>
|
||||
<g>
|
||||
<path
|
||||
fill="#1D9BF0"
|
||||
d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={cn("size-3 fill-white", className)}
|
||||
>
|
||||
<title>X</title>
|
||||
<g>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTweetHeader({
|
||||
tweet,
|
||||
}: {
|
||||
tweet: ReturnType<typeof enrichTweet>
|
||||
}) {
|
||||
const user = tweet.user
|
||||
const isVerified = user.verified || user.is_blue_verified
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between pr-0.5 w-full">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="bg-white overflow-hidden rounded-full shrink-0 size-[30px]">
|
||||
<img
|
||||
src={user.profile_image_url_https}
|
||||
alt={user.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex gap-0.5 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{user.name}
|
||||
</p>
|
||||
{isVerified && <VerifiedBadge />}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
@{user.screen_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<XLogo />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTweetMedia({
|
||||
tweet,
|
||||
}: {
|
||||
tweet: ReturnType<typeof enrichTweet>
|
||||
}) {
|
||||
const media = tweet.mediaDetails?.[0]
|
||||
if (!media) return null
|
||||
|
||||
const isVideo = media.type === "video" || media.type === "animated_gif"
|
||||
const previewUrl = media.media_url_https
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-[6px] border border-[rgba(47,50,54,0.2)]">
|
||||
<div className="relative w-full aspect-video">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Tweet media"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isVideo && (
|
||||
<div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">
|
||||
<PlayCircle className="size-8 text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TweetPreview({
|
||||
data,
|
||||
|
|
@ -26,25 +129,19 @@ export function TweetPreview({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 sm-tweet-theme w-full min-w-0",
|
||||
noBgColor
|
||||
? "bg-transparent rounded-none"
|
||||
: "bg-[#0B1017]! rounded-[18px]",
|
||||
"w-full min-w-0",
|
||||
noBgColor ? "bg-transparent" : "bg-black rounded-[18px] p-3",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TweetSkeleton />}>
|
||||
<TweetContainer
|
||||
className={cn(
|
||||
"pb-0! my-0! bg-transparent! border-none! w-full! min-w-0!",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<TweetHeader tweet={tweet} components={{}} />
|
||||
<TweetBody tweet={tweet} />
|
||||
{tweet.mediaDetails?.length ? (
|
||||
<TweetMedia tweet={tweet} components={{}} />
|
||||
) : null}
|
||||
</TweetContainer>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<CustomTweetHeader tweet={tweet} />
|
||||
<div className="sm-tweet-theme w-full min-w-0">
|
||||
<TweetBody tweet={tweet} />
|
||||
</div>
|
||||
<CustomTweetMedia tweet={tweet} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface DocumentsCommandPaletteProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
initialSearch?: string
|
||||
}
|
||||
|
||||
export function DocumentsCommandPalette({
|
||||
|
|
@ -26,6 +27,7 @@ export function DocumentsCommandPalette({
|
|||
onOpenChange,
|
||||
projectId,
|
||||
onOpenDocument,
|
||||
initialSearch = "",
|
||||
}: DocumentsCommandPaletteProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -47,10 +49,10 @@ export function DocumentsCommandPalette({
|
|||
setDocuments(queryData.pages.flatMap((page) => page.documents ?? []))
|
||||
}
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
setSearch("")
|
||||
setSearch(initialSearch)
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
}, [open, queryClient, projectId])
|
||||
}, [open, queryClient, projectId, initialSearch])
|
||||
|
||||
const filteredDocuments = useMemo(() => {
|
||||
if (!search.trim()) return documents
|
||||
|
|
|
|||
223
apps/web/components/new/fullscreen-note-modal.tsx
Normal file
223
apps/web/components/new/fullscreen-note-modal.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { Minimize2, Plus, Loader2 } from "lucide-react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { TextEditor } from "./text-editor"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
|
||||
|
||||
interface FullscreenNoteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
initialContent?: string
|
||||
onSave: (content: string) => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function FullscreenNoteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialContent = "",
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: FullscreenNoteModalProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const { setDraft } = useQuickNoteDraft(selectedProject)
|
||||
const [content, setContent] = useState(initialContent)
|
||||
const contentRef = useRef(content)
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current = content
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setContent(initialContent)
|
||||
}
|
||||
}, [isOpen, initialContent])
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
(typeof window !== "undefined" && localStorage.getItem("username")) ||
|
||||
(typeof window !== "undefined" && localStorage.getItem("userName")) ||
|
||||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const currentContent = contentRef.current
|
||||
if (currentContent.trim() && !isSaving) {
|
||||
onSave(currentContent)
|
||||
}
|
||||
}, [isSaving, onSave])
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent: string) => {
|
||||
console.log("handleContentChange", newContent)
|
||||
setContent(newContent)
|
||||
setDraft(newContent)
|
||||
},
|
||||
[setDraft],
|
||||
)
|
||||
|
||||
const canSave = content.trim().length > 0 && !isSaving
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"border-none bg-[#0D121A] flex flex-col p-0 gap-0",
|
||||
"w-screen! h-screen! max-w-none! max-h-none! rounded-none",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Note</DialogTitle>
|
||||
|
||||
<header className="flex justify-between items-center p-3 md:p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo className="h-7" />
|
||||
{userName && (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="fullscreen-close-controls"
|
||||
className="bg-[#1B1F24] rounded-[8px] px-3 py-2.5 flex items-center gap-2.5"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center justify-center h-4",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
ESC
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[#fafafa] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Close full screen"
|
||||
>
|
||||
<Minimize2 className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center px-4 md:px-[288px] pt-8 md:pt-12 pb-24 overflow-auto">
|
||||
<div className="w-full max-w-[864px] space-y-4">
|
||||
<div className="min-h-[400px] flex-1">
|
||||
<TextEditor
|
||||
content={initialContent || undefined}
|
||||
onContentChange={handleContentChange}
|
||||
onSubmit={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
id="fullscreen-save-bar"
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"bg-[#1B1F24] rounded-[8px] px-4 py-2.5 flex items-center justify-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
|
||||
) : (
|
||||
<Plus className="size-2 text-[#fafafa]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[14px] font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save note"}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4 ml-1",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="size-[10px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Enter
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
244
apps/web/components/new/highlights-card.tsx
Normal file
244
apps/web/components/new/highlights-card.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
} from "lucide-react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
|
||||
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
|
||||
|
||||
export interface HighlightItem {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
format: HighlightFormat
|
||||
query: string
|
||||
sourceDocumentIds: string[]
|
||||
}
|
||||
|
||||
interface HighlightsCardProps {
|
||||
items: HighlightItem[]
|
||||
onChat: (seed: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading?: boolean
|
||||
width?: number
|
||||
}
|
||||
|
||||
function renderContent(content: string, format: HighlightFormat) {
|
||||
switch (format) {
|
||||
case "bullets": {
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[-•*]\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
return (
|
||||
<ul className="list-disc pl-[18px] space-y-0">
|
||||
{lines.map((line, idx) => (
|
||||
<li key={idx} className="text-[12px] leading-normal">
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
case "quote":
|
||||
return (
|
||||
<p className="text-[12px] leading-normal italic border-l-2 border-[#4BA0FA] pl-2">
|
||||
"{content}"
|
||||
</p>
|
||||
)
|
||||
case "one_liner":
|
||||
return <p className="text-[12px] leading-normal font-medium">{content}</p>
|
||||
default:
|
||||
return <p className="text-[12px] leading-normal">{content}</p>
|
||||
}
|
||||
}
|
||||
|
||||
export function HighlightsCard({
|
||||
items,
|
||||
onChat,
|
||||
onShowRelated,
|
||||
isLoading = false,
|
||||
width = 216,
|
||||
}: HighlightsCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const currentItem = items[activeIndex]
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
}, [items.length])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
}, [items.length])
|
||||
|
||||
const handleChat = useCallback(() => {
|
||||
if (!currentItem) return
|
||||
const seed = `Tell me more about "${currentItem.title}"`
|
||||
onChat(seed)
|
||||
}, [currentItem, onChat])
|
||||
|
||||
const handleShowRelated = useCallback(() => {
|
||||
if (!currentItem) return
|
||||
onShowRelated(currentItem.query || currentItem.title)
|
||||
}, [currentItem, onShowRelated])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
Loading highlights...
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentItem || items.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Logo className="size-[14px]" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
|
||||
powered by
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-[11px] text-[#737373] text-center">
|
||||
Add some documents to see highlights here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div id="highlights-header" className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Logo className="size-[14px]" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
|
||||
powered by
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info className="size-[14px] text-[#737373]" />
|
||||
</div>
|
||||
|
||||
<div id="highlights-body" className="flex flex-col gap-1.5">
|
||||
<p className="text-[12px] font-semibold text-[#FAFAFA] leading-tight truncate">
|
||||
{currentItem.title}
|
||||
</p>
|
||||
<div className="text-[12px] text-[#FAFAFA] leading-normal line-clamp-5">
|
||||
{renderContent(currentItem.content, currentItem.format)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div id="highlights-actions" className="flex gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleChat}
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
style={{
|
||||
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
aria-label="Chat with Nova"
|
||||
>
|
||||
<MessageSquare className="size-3.5 text-[#FAFAFA]" />
|
||||
<span className="text-[11px] text-[#FAFAFA]">Chat</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowRelated}
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
style={{
|
||||
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
aria-label="Show related"
|
||||
>
|
||||
<Link2 className="size-3.5 text-[#FAFAFA]" />
|
||||
<span className="text-[11px] text-[#FAFAFA]">Related</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length > 1 && (
|
||||
<div id="highlights-pagination" className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{items.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
className={cn(
|
||||
"rounded-full transition-all cursor-pointer",
|
||||
idx === activeIndex
|
||||
? "w-4 h-1.5 bg-[#4BA0FA]"
|
||||
: "size-1.5 bg-[#737373] hover:bg-[#999999]",
|
||||
)}
|
||||
aria-label={`Go to item ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,14 +3,13 @@
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { $fetch } from "@repo/lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
|
||||
import type { z } from "zod"
|
||||
import { Masonry, useInfiniteLoader } from "masonic"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SuperLoader } from "@/components/superloader"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useProject } from "@/stores"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import type { Tweet } from "react-tweet/api"
|
||||
|
|
@ -24,6 +23,31 @@ import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
|
|||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { McpPreview } from "./document-cards/mcp-preview"
|
||||
import { getFaviconUrl } from "@/lib/url-helpers"
|
||||
import { QuickNoteCard } from "./quick-note-card"
|
||||
import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
| "webpage"
|
||||
| "tweet"
|
||||
| "google_drive"
|
||||
| "notion"
|
||||
| "onedrive"
|
||||
| "files"
|
||||
| "notes"
|
||||
| "mcp"
|
||||
|
||||
type DocumentFacet = {
|
||||
category: DocumentCategory
|
||||
count: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type FacetsResponse = {
|
||||
facets: DocumentFacet[]
|
||||
total: number
|
||||
}
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -37,18 +61,65 @@ const IS_DEV = process.env.NODE_ENV === "development"
|
|||
const PAGE_SIZE = IS_DEV ? 100 : 100
|
||||
const MAX_TOTAL = 1000
|
||||
|
||||
// Discriminated union for masonry items
|
||||
type MasonryItem =
|
||||
| { type: "quick-note"; id: string }
|
||||
| { type: "highlights-card"; id: string }
|
||||
| { type: "highlights-card-spacer"; id: string }
|
||||
| { type: "document"; id: string; data: DocumentWithMemories }
|
||||
|
||||
interface QuickNoteProps {
|
||||
onSave: (content: string) => void
|
||||
onMaximize: (content: string) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
interface HighlightsProps {
|
||||
items: HighlightItem[]
|
||||
onChat: (seed: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
interface MemoriesGridProps {
|
||||
isChatOpen: boolean
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
quickNoteProps?: QuickNoteProps
|
||||
highlightsProps?: HighlightsProps
|
||||
}
|
||||
|
||||
export function MemoriesGrid({
|
||||
isChatOpen,
|
||||
onOpenDocument,
|
||||
quickNoteProps,
|
||||
highlightsProps,
|
||||
}: MemoriesGridProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useState<
|
||||
DocumentCategory[]
|
||||
>([])
|
||||
|
||||
const { data: facetsData } = useQuery({
|
||||
queryKey: ["document-facets", selectedProject],
|
||||
queryFn: async (): Promise<FacetsResponse> => {
|
||||
const response = await $fetch("@post/documents/documents/facets", {
|
||||
body: {
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to fetch facets")
|
||||
}
|
||||
|
||||
return response.data as FacetsResponse
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const {
|
||||
data,
|
||||
|
|
@ -58,7 +129,7 @@ export function MemoriesGrid({
|
|||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<DocumentsResponse, Error>({
|
||||
queryKey: ["documents-with-memories", selectedProject],
|
||||
queryKey: ["documents-with-memories", selectedProject, selectedCategories],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
|
|
@ -68,6 +139,8 @@ export function MemoriesGrid({
|
|||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedProject ? [selectedProject] : undefined,
|
||||
categories:
|
||||
selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
|
|
@ -95,12 +168,58 @@ export function MemoriesGrid({
|
|||
enabled: !!user,
|
||||
})
|
||||
|
||||
const handleCategoryToggle = useCallback((category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter((c) => c !== category)
|
||||
}
|
||||
return [...prev, category]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedCategories([])
|
||||
}, [])
|
||||
|
||||
const documents = useMemo(() => {
|
||||
return (
|
||||
data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? []
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const hasQuickNote = !!quickNoteProps
|
||||
const hasHighlights = !!highlightsProps
|
||||
|
||||
const masonryItems: MasonryItem[] = useMemo(() => {
|
||||
const items: MasonryItem[] = []
|
||||
|
||||
if (!isMobile) {
|
||||
if (hasQuickNote) {
|
||||
items.push({ type: "quick-note", id: "quick-note" })
|
||||
}
|
||||
if (hasHighlights) {
|
||||
items.push({ type: "highlights-card", id: "highlights-card" })
|
||||
// Add spacer to occupy the second column space for the 2-column highlights card
|
||||
items.push({
|
||||
type: "highlights-card-spacer",
|
||||
id: "highlights-card-spacer",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
items.push({ type: "document", id: doc.id, data: doc })
|
||||
}
|
||||
|
||||
return items
|
||||
}, [documents, isMobile, hasQuickNote, hasHighlights])
|
||||
|
||||
// Stable key for Masonry based on document IDs, not item values
|
||||
const masonryKey = useMemo(() => {
|
||||
const docIds = documents.map((d) => d.id).join(",")
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}-${hasHighlights}`
|
||||
}, [documents, isChatOpen, hasQuickNote, hasHighlights])
|
||||
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
const loadMoreDocuments = useCallback(async (): Promise<void> => {
|
||||
|
|
@ -131,24 +250,61 @@ export function MemoriesGrid({
|
|||
[onOpenDocument],
|
||||
)
|
||||
|
||||
const renderDocumentCard = useCallback(
|
||||
const renderMasonryItem = useCallback(
|
||||
({
|
||||
index,
|
||||
data,
|
||||
width,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories
|
||||
data: MasonryItem
|
||||
width: number
|
||||
}) => (
|
||||
<DocumentCard
|
||||
index={index}
|
||||
data={data}
|
||||
width={width}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
),
|
||||
[handleCardClick],
|
||||
}) => {
|
||||
if (data.type === "quick-note" && quickNoteProps) {
|
||||
return (
|
||||
<div className="p-2" style={{ width }}>
|
||||
<QuickNoteCard {...quickNoteProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "highlights-card" && highlightsProps) {
|
||||
const doubleWidth = width * 2
|
||||
const cardWidth = doubleWidth - 16
|
||||
return (
|
||||
<div className="p-2" style={{ width: doubleWidth }}>
|
||||
<HighlightsCard {...highlightsProps} width={cardWidth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "highlights-card-spacer") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height: 220, // Approximate height of HighlightsCard
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.type === "document") {
|
||||
return (
|
||||
<DocumentCard
|
||||
index={index}
|
||||
data={data.data}
|
||||
width={width}
|
||||
onClick={handleCardClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
[handleCardClick, quickNoteProps, highlightsProps],
|
||||
)
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -163,15 +319,37 @@ export function MemoriesGrid({
|
|||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-4 py-2 data-[state=active]:bg-[#00173C] data-[state=active]:border-[#2261CA33] mb-4",
|
||||
)}
|
||||
data-state="active"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<div id="filter-pills" className="flex flex-wrap gap-1.5 mb-3">
|
||||
<Button
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.length === 0 &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
All
|
||||
{facetsData?.total !== undefined && (
|
||||
<span className="ml-1 text-[#737373]">({facetsData.total})</span>
|
||||
)}
|
||||
</Button>
|
||||
{facetsData?.facets.map((facet: DocumentFacet) => (
|
||||
<Button
|
||||
key={facet.category}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
|
||||
selectedCategories.includes(facet.category) &&
|
||||
"bg-[#00173C] border-[#2261CA33]",
|
||||
)}
|
||||
onClick={() => handleCategoryToggle(facet.category)}
|
||||
>
|
||||
{facet.label}
|
||||
<span className="ml-1 text-[#737373]">({facet.count})</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
|
|
@ -191,9 +369,9 @@ export function MemoriesGrid({
|
|||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
<Masonry
|
||||
key={`masonry-${documents.length}-${documents.map((d) => d.id).join(",")}-${isChatOpen}`}
|
||||
items={documents}
|
||||
render={renderDocumentCard}
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={216}
|
||||
|
|
|
|||
159
apps/web/components/new/quick-note-card.tsx
Normal file
159
apps/web/components/new/quick-note-card.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useCallback } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Maximize2, Plus, Loader2 } from "lucide-react"
|
||||
import { useProject } from "@/stores"
|
||||
import { useQuickNoteDraft } from "@/stores/quick-note-draft"
|
||||
|
||||
interface QuickNoteCardProps {
|
||||
onSave: (content: string) => void
|
||||
onMaximize: (content: string) => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function QuickNoteCard({
|
||||
onSave,
|
||||
onMaximize,
|
||||
isSaving = false,
|
||||
}: QuickNoteCardProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { draft, setDraft } = useQuickNoteDraft(selectedProject)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraft(e.target.value)
|
||||
},
|
||||
[setDraft],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (draft.trim() && !isSaving) {
|
||||
onSave(draft)
|
||||
}
|
||||
}
|
||||
},
|
||||
[draft, isSaving, onSave],
|
||||
)
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
if (draft.trim() && !isSaving) {
|
||||
onSave(draft)
|
||||
}
|
||||
}, [draft, isSaving, onSave])
|
||||
|
||||
const handleMaximizeClick = useCallback(() => {
|
||||
onMaximize(draft)
|
||||
}, [draft, onMaximize])
|
||||
|
||||
const canSave = draft.trim().length > 0 && !isSaving
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-[#1B1F24] rounded-[22px] p-1"
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="quick-note-inner"
|
||||
className="bg-[#0B1017] rounded-[18px] p-3 relative"
|
||||
style={{
|
||||
boxShadow: "inset 1.421px 1.421px 4.263px 0 rgba(11, 15, 21, 0.4)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximizeClick}
|
||||
className="absolute top-3 right-3 text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Expand to full screen"
|
||||
>
|
||||
<Maximize2 className="size-[14px]" />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Start writing..."
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"w-full h-[120px] bg-transparent resize-none outline-none text-[12px] leading-normal text-white placeholder:text-[#737373] pr-5 disabled:opacity-50",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="quick-note-action-bar"
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center justify-center gap-8 w-full"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 4px 20px 0 rgba(0, 0, 0, 0.25), inset 1px 1px 1px 0 rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{isSaving ? (
|
||||
<Loader2 className="size-2 animate-spin text-[#fafafa]" />
|
||||
) : (
|
||||
<Plus className="size-2 text-[#fafafa]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save note"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[rgba(33,33,33,0.5)] border border-[rgba(115,115,115,0.2)] rounded px-1 py-0.5 flex items-center gap-1 h-4",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="size-[10px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Enter
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ export function TextEditor({
|
|||
handleKeyDown: (_view, event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
debouncedUpdates.flush()
|
||||
onSubmitRef.current?.()
|
||||
return true
|
||||
}
|
||||
|
|
@ -104,9 +105,11 @@ export function TextEditor({
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Flush any pending debounced updates before destroying editor
|
||||
debouncedUpdates.flush()
|
||||
editor?.destroy()
|
||||
}
|
||||
}, [editor])
|
||||
}, [editor, debouncedUpdates])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -44,17 +44,16 @@ export function SuperLoader({
|
|||
const animateVariant = prefersReducedMotion ? "static" : "visible"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
<output
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-col items-center gap-2 ${className}`}
|
||||
className={`inline-flex flex-row 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}
|
||||
width={size * 0.5}
|
||||
height={size * 0.5}
|
||||
className={`shrink-0 ${colorClassName}`}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
|
|
@ -93,6 +92,6 @@ export function SuperLoader({
|
|||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</output>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useMemo, useState } from "react"
|
|||
import { analytics } from "@/lib/analytics"
|
||||
import { useChatOpen, usePersistentChat, useProject } from "@/stores"
|
||||
import { ChatMessages } from "./chat-messages"
|
||||
import { generateId } from "@lib/generate-id"
|
||||
|
||||
export function ChatRewrite() {
|
||||
const { setIsOpen } = useChatOpen()
|
||||
|
|
@ -34,7 +35,7 @@ export function ChatRewrite() {
|
|||
|
||||
function handleNewChat() {
|
||||
analytics.newChatStarted()
|
||||
const newId = crypto.randomUUID()
|
||||
const newId = generateId()
|
||||
setCurrentChatId(newId)
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,17 @@
|
|||
|
||||
.tweet-body-module__ZNRZja__root > * {
|
||||
font-size: 10px !important;
|
||||
line-height: 0.25rem !important;
|
||||
line-height: 1.35 !important;
|
||||
letter-spacing: -0.1px !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.sm-tweet-theme .tweet-body-module__ZNRZja__root {
|
||||
font-family: "DM Sans", sans-serif !important;
|
||||
font-weight: 500 !important;
|
||||
color: #fafafa !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
[class*="dmSans"],
|
||||
|
|
|
|||
54
apps/web/stores/quick-note-draft.ts
Normal file
54
apps/web/stores/quick-note-draft.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
interface QuickNoteDraftState {
|
||||
draftByProject: Record<string, string>
|
||||
setDraft: (projectId: string, draft: string) => void
|
||||
resetDraft: (projectId: string) => void
|
||||
}
|
||||
|
||||
export const useQuickNoteDraftStore = create<QuickNoteDraftState>()(
|
||||
(set, get) => ({
|
||||
draftByProject: {},
|
||||
|
||||
setDraft: (projectId, draft) => {
|
||||
const current = get().draftByProject[projectId]
|
||||
if (current === draft) return
|
||||
set((state) => ({
|
||||
draftByProject: {
|
||||
...state.draftByProject,
|
||||
[projectId]: draft,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
resetDraft: (projectId) => {
|
||||
const current = get().draftByProject[projectId]
|
||||
if (current === undefined || current === "") return
|
||||
set((state) => ({
|
||||
draftByProject: {
|
||||
...state.draftByProject,
|
||||
[projectId]: "",
|
||||
},
|
||||
}))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export function useQuickNoteDraft(projectId: string) {
|
||||
const draft = useQuickNoteDraftStore(
|
||||
(s) => s.draftByProject[projectId] ?? "",
|
||||
)
|
||||
const setDraft = useQuickNoteDraftStore((s) => s.setDraft)
|
||||
const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft: (value: string) => setDraft(projectId, value),
|
||||
resetDraft: () => resetDraft(projectId),
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuickNoteDraftReset(projectId: string) {
|
||||
const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)
|
||||
return () => resetDraft(projectId)
|
||||
}
|
||||
|
|
@ -414,12 +414,45 @@ interface SupermemoryToolsConfig {
|
|||
baseUrl?: string
|
||||
containerTags?: string[]
|
||||
projectId?: string
|
||||
strict?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
- **baseUrl**: Custom base URL for the supermemory API
|
||||
- **containerTags**: Array of custom container tags (mutually exclusive with projectId)
|
||||
- **projectId**: Project ID which gets converted to container tag format (mutually exclusive with containerTags)
|
||||
- **strict**: Enable strict schema mode for OpenAI strict validation. When `true`, all schema properties are required (satisfies OpenAI strict mode). When `false` (default), optional fields remain optional for maximum compatibility with all models.
|
||||
|
||||
### OpenAI Strict Mode Compatibility
|
||||
|
||||
When using OpenAI-compatible providers with strict schema validation (e.g., OpenRouter with Azure OpenAI backend), enable strict mode to ensure all schema properties are included in the `required` array:
|
||||
|
||||
```typescript
|
||||
import { searchMemoriesTool, addMemoryTool } from "@supermemory/tools/ai-sdk"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { streamText } from "ai"
|
||||
|
||||
const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY })
|
||||
|
||||
const tools = {
|
||||
searchMemories: searchMemoriesTool(apiKey, {
|
||||
containerTags: [userId],
|
||||
strict: true // ✅ Required for OpenAI strict mode
|
||||
}),
|
||||
addMemory: addMemoryTool(apiKey, {
|
||||
containerTags: [userId],
|
||||
strict: true
|
||||
}),
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: openrouter.chat("openai/gpt-5-nano"),
|
||||
messages: [...],
|
||||
tools,
|
||||
})
|
||||
```
|
||||
|
||||
Without `strict: true`, optional fields like `includeFullDocs` and `limit` won't be in the `required` array, which will cause validation errors with OpenAI strict mode.
|
||||
|
||||
### withSupermemory Middleware Options
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const searchMemoriesTool = (
|
|||
})
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
const strict = config?.strict ?? false
|
||||
|
||||
return tool({
|
||||
description: TOOL_DESCRIPTIONS.searchMemories,
|
||||
|
|
@ -27,16 +28,26 @@ export const searchMemoriesTool = (
|
|||
informationToGet: z
|
||||
.string()
|
||||
.describe(PARAMETER_DESCRIPTIONS.informationToGet),
|
||||
includeFullDocs: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.includeFullDocs)
|
||||
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit),
|
||||
includeFullDocs: strict
|
||||
? z
|
||||
.boolean()
|
||||
.default(DEFAULT_VALUES.includeFullDocs)
|
||||
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs)
|
||||
: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.includeFullDocs)
|
||||
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
|
||||
limit: strict
|
||||
? z
|
||||
.number()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit)
|
||||
: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit),
|
||||
}),
|
||||
execute: async ({
|
||||
informationToGet,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,22 @@
|
|||
* Only one of `projectId` or `containerTags` can be provided.
|
||||
*/
|
||||
export interface SupermemoryToolsConfig {
|
||||
/**
|
||||
* Custom base URL for the supermemory API
|
||||
*/
|
||||
baseUrl?: string
|
||||
/**
|
||||
* Array of custom container tags (mutually exclusive with projectId)
|
||||
*/
|
||||
containerTags?: string[]
|
||||
/**
|
||||
* Project ID which gets converted to container tag format (mutually exclusive with containerTags)
|
||||
*/
|
||||
projectId?: string
|
||||
/**
|
||||
* Enable strict schema mode for OpenAI strict validation.
|
||||
* When true, all schema properties are required (satisfies OpenAI strict mode).
|
||||
* When false (default), optional fields remain optional for maximum compatibility.
|
||||
*/
|
||||
strict?: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue