mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
fix
This commit is contained in:
commit
492024bd48
16 changed files with 1632 additions and 573 deletions
18
apps/browser-extension/components/icons.tsx
Normal file
18
apps/browser-extension/components/icons.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function RightArrow({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<title>Right arrow</title>
|
||||
<path
|
||||
d="M-1.26511e-05 5.82399V4.53599H7.81199L3.90599 0.895994L4.78799 -6.19888e-06L9.79999 4.77399V5.54399L4.78799 10.332L3.90599 9.43599L7.78399 5.82399H-1.26511e-05Z"
|
||||
fill="#737373"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { initializeT3 } from "./t3"
|
|||
import {
|
||||
handleTwitterNavigation,
|
||||
initializeTwitter,
|
||||
openImportModal,
|
||||
updateTwitterImportUI,
|
||||
} from "./twitter"
|
||||
|
||||
|
|
@ -29,6 +30,8 @@ export default defineContentScript({
|
|||
await saveMemory()
|
||||
} else if (message.action === MESSAGE_TYPES.OPEN_SEARCH_PANEL) {
|
||||
handleOpenSearchPanel(message.data as string)
|
||||
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
|
||||
await openImportModal()
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
||||
updateTwitterImportUI(message)
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import {
|
|||
ELEMENT_IDS,
|
||||
MESSAGE_TYPES,
|
||||
POSTHOG_EVENT_KEY,
|
||||
STORAGE_KEYS,
|
||||
UI_CONFIG,
|
||||
} from "../../utils/constants"
|
||||
import { trackEvent } from "../../utils/posthog"
|
||||
import {
|
||||
createTwitterImportButton,
|
||||
createProjectSelectionModal,
|
||||
createSaveTweetElement,
|
||||
DOMUtils,
|
||||
|
|
@ -27,46 +28,105 @@ async function loadSpaceGroteskFonts(): Promise<void> {
|
|||
await document.fonts.ready
|
||||
}
|
||||
|
||||
export function initializeTwitter() {
|
||||
/**
|
||||
* Check if import intent is valid (exists and not expired)
|
||||
*/
|
||||
async function checkAndConsumeImportIntent(): Promise<boolean> {
|
||||
try {
|
||||
const result = await browser.storage.local.get(
|
||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
|
||||
)
|
||||
const intentUntil = result[
|
||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL
|
||||
] as number | undefined
|
||||
|
||||
if (intentUntil && Date.now() < intentUntil) {
|
||||
await browser.storage.local.remove(
|
||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error("Error checking import intent:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding toast has been shown before
|
||||
*/
|
||||
async function hasOnboardingBeenShown(): Promise<boolean> {
|
||||
try {
|
||||
const result = await browser.storage.local.get(
|
||||
STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN,
|
||||
)
|
||||
return !!result[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]
|
||||
} catch (error) {
|
||||
console.error("Error checking onboarding status:", error)
|
||||
return true // Default to true to avoid showing toast on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding toast as shown
|
||||
*/
|
||||
async function markOnboardingAsShown(): Promise<void> {
|
||||
try {
|
||||
await browser.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error marking onboarding as shown:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeTwitter() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
if (window.location.pathname === "/i/bookmarks") {
|
||||
setTimeout(() => {
|
||||
addTwitterImportButton()
|
||||
addTwitterImportButtonForFolders()
|
||||
setTimeout(async () => {
|
||||
if (window.location.pathname === "/i/bookmarks") {
|
||||
await handleBookmarksPageLoad()
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
// Remove button if not on bookmarks page
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
|
||||
}
|
||||
// Clean up any injected UI if navigating away
|
||||
removeAllTwitterUI()
|
||||
}
|
||||
}
|
||||
|
||||
function addTwitterImportButton() {
|
||||
/**
|
||||
* Handle what to show when user lands on bookmarks page
|
||||
*/
|
||||
async function handleBookmarksPageLoad() {
|
||||
if (window.location.pathname !== "/i/bookmarks") {
|
||||
return
|
||||
}
|
||||
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
||||
addTwitterImportButtonForFolders() // Add buttons to bookmark folders
|
||||
|
||||
const hasIntent = await checkAndConsumeImportIntent()
|
||||
|
||||
if (hasIntent) {
|
||||
await openImportModal()
|
||||
return
|
||||
}
|
||||
|
||||
const button = createTwitterImportButton(async () => {
|
||||
try {
|
||||
await handleAllBookmarksImportClick()
|
||||
} catch (error) {
|
||||
console.error("Error starting import:", error)
|
||||
}
|
||||
})
|
||||
const onboardingShown = await hasOnboardingBeenShown()
|
||||
|
||||
document.body.appendChild(button)
|
||||
if (!onboardingShown) {
|
||||
await showOnboardingToast()
|
||||
await markOnboardingAsShown()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAllBookmarksImportClick() {
|
||||
/**
|
||||
* Opens the import modal and handles the import flow
|
||||
*/
|
||||
export async function openImportModal() {
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.FETCH_PROJECTS,
|
||||
|
|
@ -85,14 +145,13 @@ async function handleAllBookmarksImportClick() {
|
|||
await showAllBookmarksProjectModal(projects)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling all bookmarks import:", error)
|
||||
console.error("Error opening import modal:", error)
|
||||
await browser.runtime.sendMessage({
|
||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function showAllBookmarksProjectModal(
|
||||
projects: Array<{ id: string; name: string; containerTag: string }>,
|
||||
) {
|
||||
|
|
@ -124,6 +183,420 @@ async function showAllBookmarksProjectModal(
|
|||
document.body.appendChild(modal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the one-time onboarding toast with progress bar
|
||||
*/
|
||||
async function showOnboardingToast() {
|
||||
await loadSpaceGroteskFonts()
|
||||
|
||||
// Remove any existing toast
|
||||
const existingToast = document.getElementById(
|
||||
ELEMENT_IDS.TWITTER_ONBOARDING_TOAST,
|
||||
)
|
||||
if (existingToast) {
|
||||
existingToast.remove()
|
||||
}
|
||||
|
||||
const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION
|
||||
|
||||
// Create toast container
|
||||
const toast = document.createElement("div")
|
||||
toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483647;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
min-width: 320px;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
|
||||
animation: smSlideInUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
// Add keyframe animations if not already present
|
||||
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
|
||||
const style = document.createElement("style")
|
||||
style.id = "supermemory-onboarding-toast-styles"
|
||||
style.textContent = `
|
||||
@keyframes smSlideInUp {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes smFadeOut {
|
||||
from { transform: translateY(0); opacity: 1; }
|
||||
to { transform: translateY(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes smProgressGrow {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
}
|
||||
@keyframes smPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Header with icon, text and close button
|
||||
const header = document.createElement("div")
|
||||
header.style.cssText =
|
||||
"display: flex; align-items: flex-start; gap: 12px; position: relative;"
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const icon = document.createElement("img")
|
||||
icon.src = iconUrl
|
||||
icon.alt = "Supermemory"
|
||||
icon.style.cssText = "width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
|
||||
|
||||
const textContainer = document.createElement("div")
|
||||
textContainer.style.cssText = "display: flex; flex-direction: column; gap: 4px; flex: 1;"
|
||||
|
||||
const title = document.createElement("span")
|
||||
title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;"
|
||||
title.textContent = "Import X/Twitter Bookmarks"
|
||||
|
||||
const description = document.createElement("span")
|
||||
description.style.cssText = "font-size: 13px; color: #6b7280; line-height: 1.4;"
|
||||
description.textContent =
|
||||
"You can import all your Twitter bookmarks to Supermemory with one click."
|
||||
|
||||
textContainer.appendChild(title)
|
||||
textContainer.appendChild(description)
|
||||
|
||||
// Close button
|
||||
const closeButton = document.createElement("button")
|
||||
closeButton.setAttribute("aria-label", "Close onboarding toast")
|
||||
closeButton.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
`
|
||||
closeButton.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`
|
||||
closeButton.addEventListener("mouseenter", () => {
|
||||
closeButton.style.backgroundColor = "#f3f4f6"
|
||||
})
|
||||
closeButton.addEventListener("mouseleave", () => {
|
||||
closeButton.style.backgroundColor = "transparent"
|
||||
})
|
||||
closeButton.addEventListener("click", () => {
|
||||
dismissToast(toast)
|
||||
})
|
||||
|
||||
header.appendChild(icon)
|
||||
header.appendChild(textContainer)
|
||||
header.appendChild(closeButton)
|
||||
|
||||
// Action buttons
|
||||
const buttonsContainer = document.createElement("div")
|
||||
buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;"
|
||||
|
||||
const importButton = document.createElement("button")
|
||||
importButton.style.cssText = `
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
font-family: inherit;
|
||||
`
|
||||
importButton.textContent = "Import now"
|
||||
importButton.addEventListener("mouseenter", () => {
|
||||
importButton.style.opacity = "0.9"
|
||||
})
|
||||
importButton.addEventListener("mouseleave", () => {
|
||||
importButton.style.opacity = "1"
|
||||
})
|
||||
importButton.addEventListener("click", async () => {
|
||||
dismissToast(toast)
|
||||
await openImportModal()
|
||||
})
|
||||
|
||||
const learnMoreButton = document.createElement("button")
|
||||
learnMoreButton.style.cssText = `
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-family: inherit;
|
||||
`
|
||||
learnMoreButton.textContent = "Learn more"
|
||||
learnMoreButton.addEventListener("mouseenter", () => {
|
||||
learnMoreButton.style.backgroundColor = "#f9fafb"
|
||||
})
|
||||
learnMoreButton.addEventListener("mouseleave", () => {
|
||||
learnMoreButton.style.backgroundColor = "transparent"
|
||||
})
|
||||
learnMoreButton.addEventListener("click", () => {
|
||||
window.open(
|
||||
"https://docs.supermemory.ai/connectors/twitter",
|
||||
"_blank",
|
||||
)
|
||||
})
|
||||
|
||||
buttonsContainer.appendChild(importButton)
|
||||
buttonsContainer.appendChild(learnMoreButton)
|
||||
|
||||
// Progress bar container
|
||||
const progressBarContainer = document.createElement("div")
|
||||
progressBarContainer.setAttribute("role", "progressbar")
|
||||
progressBarContainer.setAttribute("aria-valuemin", "0")
|
||||
progressBarContainer.setAttribute("aria-valuemax", "100")
|
||||
progressBarContainer.setAttribute("aria-valuenow", "0")
|
||||
progressBarContainer.setAttribute("aria-label", "Onboarding toast auto-dismiss progress")
|
||||
progressBarContainer.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: #e5e7eb;
|
||||
`
|
||||
|
||||
const progressBar = document.createElement("div")
|
||||
progressBar.style.cssText = `
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0);
|
||||
transform-origin: left;
|
||||
animation: smProgressGrow ${duration}ms linear forwards;
|
||||
`
|
||||
|
||||
// Update progress bar ARIA value as animation progresses
|
||||
const startTime = Date.now()
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(100, Math.round((elapsed / duration) * 100))
|
||||
progressBarContainer.setAttribute("aria-valuenow", String(progress))
|
||||
if (progress < 100) {
|
||||
requestAnimationFrame(updateProgress)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updateProgress)
|
||||
|
||||
progressBarContainer.appendChild(progressBar)
|
||||
|
||||
// Assemble toast
|
||||
toast.appendChild(header)
|
||||
toast.appendChild(buttonsContainer)
|
||||
toast.appendChild(progressBarContainer)
|
||||
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Auto-dismiss after duration
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
dismissToast(toast)
|
||||
}
|
||||
}, duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the toast with animation
|
||||
*/
|
||||
function dismissToast(toast: HTMLElement) {
|
||||
toast.style.animation = "smFadeOut 0.3s ease-out forwards"
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
toast.remove()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all Twitter-specific injected UI
|
||||
*/
|
||||
function removeAllTwitterUI() {
|
||||
// Remove import button (legacy)
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
|
||||
}
|
||||
// Remove onboarding toast
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) {
|
||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)
|
||||
}
|
||||
// Remove import progress toast
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) {
|
||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
|
||||
}
|
||||
// Remove any folder buttons
|
||||
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
|
||||
button.remove()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or updates the import progress toast in the bottom-right
|
||||
*/
|
||||
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
|
||||
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
|
||||
|
||||
if (!toast) {
|
||||
// Ensure animation styles are available
|
||||
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
|
||||
const style = document.createElement("style")
|
||||
style.id = "supermemory-onboarding-toast-styles"
|
||||
style.textContent = `
|
||||
@keyframes smSlideInUp {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes smFadeOut {
|
||||
from { transform: translateY(0); opacity: 1; }
|
||||
to { transform: translateY(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes smPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Create new toast
|
||||
toast = document.createElement("div")
|
||||
toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 2147483647;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
|
||||
animation: smSlideInUp 0.3s ease-out;
|
||||
`
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const icon = document.createElement("img")
|
||||
icon.src = iconUrl
|
||||
icon.alt = "Supermemory"
|
||||
icon.id = "sm-import-progress-icon"
|
||||
icon.style.cssText =
|
||||
"width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;"
|
||||
|
||||
const textSpan = document.createElement("span")
|
||||
textSpan.id = "sm-import-progress-text"
|
||||
textSpan.style.cssText = "font-weight: 500; flex: 1;"
|
||||
textSpan.textContent = message
|
||||
|
||||
toast.appendChild(icon)
|
||||
toast.appendChild(textSpan)
|
||||
document.body.appendChild(toast)
|
||||
} else {
|
||||
// Update existing toast
|
||||
const textSpan = toast.querySelector(
|
||||
"#sm-import-progress-text",
|
||||
) as HTMLSpanElement
|
||||
if (textSpan) {
|
||||
textSpan.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
// Style for completion
|
||||
if (isComplete) {
|
||||
const icon = toast.querySelector(
|
||||
"#sm-import-progress-icon",
|
||||
) as HTMLImageElement
|
||||
if (icon) {
|
||||
icon.style.animation = "none"
|
||||
icon.style.opacity = "1"
|
||||
}
|
||||
|
||||
const textSpan = toast.querySelector(
|
||||
"#sm-import-progress-text",
|
||||
) as HTMLSpanElement
|
||||
if (textSpan) {
|
||||
textSpan.style.color = "#059669"
|
||||
}
|
||||
|
||||
// Auto-dismiss after 4 seconds on completion
|
||||
setTimeout(() => {
|
||||
const existingToast = document.getElementById(
|
||||
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
|
||||
)
|
||||
if (existingToast) {
|
||||
dismissToast(existingToast)
|
||||
}
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTwitterImportUI(message: {
|
||||
type: string
|
||||
importedMessage?: string
|
||||
totalImported?: number
|
||||
}) {
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
|
||||
showOrUpdateImportProgressToast(message.importedMessage, false)
|
||||
}
|
||||
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
showOrUpdateImportProgressToast(
|
||||
`✓ Imported ${message.totalImported} tweets!`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTwitterNavigation() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/i/bookmarks") {
|
||||
addTwitterImportButtonForFolders()
|
||||
await handleBookmarksPageLoad()
|
||||
} else {
|
||||
removeAllTwitterUI()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds import buttons to bookmark folders
|
||||
*/
|
||||
function addTwitterImportButtonForFolders() {
|
||||
if (window.location.pathname !== "/i/bookmarks") {
|
||||
return
|
||||
|
|
@ -138,6 +611,9 @@ function addTwitterImportButtonForFolders() {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an import button to a bookmark folder element
|
||||
*/
|
||||
function addButtonToElement(element: HTMLElement) {
|
||||
if (element.querySelector("[data-supermemory-button]")) {
|
||||
return
|
||||
|
|
@ -148,9 +624,8 @@ function addButtonToElement(element: HTMLElement) {
|
|||
const button = createSaveTweetElement(async () => {
|
||||
const url = element.getAttribute("href")
|
||||
const bookmarkCollectionId = url?.split("/").pop()
|
||||
console.log("Bookmark collection ID:", bookmarkCollectionId)
|
||||
if (bookmarkCollectionId) {
|
||||
await showProjectSelectionModal(bookmarkCollectionId)
|
||||
await showFolderProjectSelectionModal(bookmarkCollectionId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -164,132 +639,55 @@ function addButtonToElement(element: HTMLElement) {
|
|||
element.style.padding = "10px"
|
||||
}
|
||||
|
||||
export function updateTwitterImportUI(message: {
|
||||
type: string
|
||||
importedMessage?: string
|
||||
totalImported?: number
|
||||
}) {
|
||||
const importButton = document.getElementById(
|
||||
ELEMENT_IDS.TWITTER_IMPORT_BUTTON,
|
||||
)
|
||||
if (!importButton) return
|
||||
|
||||
const existingImg = importButton.querySelector("img")
|
||||
if (existingImg) {
|
||||
existingImg.remove()
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
importButton.style.backgroundImage = `url("${iconUrl}")`
|
||||
importButton.style.backgroundRepeat = "no-repeat"
|
||||
importButton.style.backgroundSize = "20px 20px"
|
||||
importButton.style.backgroundPosition = "8px center"
|
||||
importButton.style.padding = "10px 16px 10px 32px"
|
||||
}
|
||||
|
||||
let textSpan = importButton.querySelector(
|
||||
"#sm-import-text",
|
||||
) as HTMLSpanElement
|
||||
if (!textSpan) {
|
||||
textSpan = document.createElement("span")
|
||||
textSpan.id = "sm-import-text"
|
||||
textSpan.style.cssText = "font-weight: 500; font-size: 14px;"
|
||||
importButton.appendChild(textSpan)
|
||||
}
|
||||
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
||||
textSpan.textContent = message.importedMessage || ""
|
||||
importButton.style.cursor = "default"
|
||||
}
|
||||
|
||||
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
textSpan.textContent = `✓ Imported ${message.totalImported} tweets!`
|
||||
textSpan.style.color = "#059669"
|
||||
|
||||
setTimeout(() => {
|
||||
textSpan.textContent = "Import Bookmarks"
|
||||
textSpan.style.color = ""
|
||||
importButton.style.cursor = "pointer"
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTwitterNavigation() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.location.pathname === "/i/bookmarks") {
|
||||
addTwitterImportButton()
|
||||
addTwitterImportButtonForFolders()
|
||||
} else {
|
||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
|
||||
}
|
||||
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
|
||||
button.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the project selection modal for folder imports
|
||||
* @param bookmarkCollectionId - The ID of the bookmark collection to import
|
||||
*/
|
||||
async function showProjectSelectionModal(bookmarkCollectionId: string) {
|
||||
try {
|
||||
const modal = createProjectSelectionModal(
|
||||
[],
|
||||
async (selectedProject) => {
|
||||
modal.remove()
|
||||
async function showFolderProjectSelectionModal(bookmarkCollectionId: string) {
|
||||
await loadSpaceGroteskFonts()
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
||||
isFolderImport: true,
|
||||
bookmarkCollectionId: bookmarkCollectionId,
|
||||
selectedProject: selectedProject,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error importing bookmarks:", error)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
modal.remove()
|
||||
},
|
||||
)
|
||||
const modal = createProjectSelectionModal(
|
||||
[],
|
||||
async (selectedProject) => {
|
||||
modal.remove()
|
||||
|
||||
document.body.appendChild(modal)
|
||||
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.FETCH_PROJECTS,
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
const projects = response.data
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.warn("No projects available for import")
|
||||
updateModalWithProjects(modal, [])
|
||||
} else {
|
||||
updateModalWithProjects(modal, projects)
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch projects:", response.error)
|
||||
updateModalWithProjects(modal, [])
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
||||
isFolderImport: true,
|
||||
bookmarkCollectionId: bookmarkCollectionId,
|
||||
selectedProject: selectedProject,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error importing bookmarks:", error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching projects:", error)
|
||||
},
|
||||
() => {
|
||||
modal.remove()
|
||||
},
|
||||
)
|
||||
|
||||
document.body.appendChild(modal)
|
||||
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.FETCH_PROJECTS,
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
const projects = response.data
|
||||
updateModalWithProjects(modal, projects)
|
||||
} else {
|
||||
console.error("Failed to fetch projects:", response.error)
|
||||
updateModalWithProjects(modal, [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error showing project selection modal:", error)
|
||||
console.error("Error fetching projects:", error)
|
||||
updateModalWithProjects(modal, [])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modal with fetched projects
|
||||
* @param modal - The modal element
|
||||
* @param projects - Array of projects to populate the dropdown
|
||||
*/
|
||||
function updateModalWithProjects(
|
||||
modal: HTMLElement,
|
||||
|
|
@ -316,10 +714,10 @@ function updateModalWithProjects(
|
|||
importButton.disabled = true
|
||||
importButton.style.cssText = `
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #d1d5db;
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
|||
import { useEffect, useState } from "react"
|
||||
import "./App.css"
|
||||
import { validateAuthToken } from "../../utils/api"
|
||||
import { MESSAGE_TYPES } from "../../utils/constants"
|
||||
import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants"
|
||||
import {
|
||||
useDefaultProject,
|
||||
useProjects,
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
userData as userDataStorage,
|
||||
} from "../../utils/storage"
|
||||
import type { Project } from "../../utils/types"
|
||||
import { RightArrow } from "@/components/icons"
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
|
|
@ -181,6 +182,11 @@ function App() {
|
|||
}
|
||||
}, [defaultProject, projects, setDefaultProjectMutation])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes
|
||||
useEffect(() => {
|
||||
setShowProjectSelector(false)
|
||||
}, [activeTab])
|
||||
|
||||
const handleSaveCurrentPage = async () => {
|
||||
setSaving(true)
|
||||
|
||||
|
|
@ -262,25 +268,51 @@ function App() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 p-2.5 border-b border-gray-200 relative">
|
||||
<img
|
||||
alt="supermemory"
|
||||
className="w-8 h-8 shrink-0"
|
||||
src="./dark-transparent.svg"
|
||||
style={{ width: "80%", height: "45px" }}
|
||||
/>
|
||||
<div
|
||||
className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
boxShadow:
|
||||
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="popup-header"
|
||||
className="flex items-center justify-between p-2.5 relative"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
|
||||
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
|
||||
>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./icon-48.png"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium text-[#737373] leading-normal">
|
||||
Your
|
||||
</span>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./logo-fullmark.svg"
|
||||
className="h-[14.5px] w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 text-black min-h-[300px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-base text-black">
|
||||
<div className="p-4 min-h-[300px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-sm text-[#737373]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-black"
|
||||
className="text-[#737373]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">
|
||||
|
|
@ -294,7 +326,7 @@ function App() {
|
|||
</path>
|
||||
</svg>
|
||||
|
||||
<span className="font-semibold">Loading...</span>
|
||||
<span className="font-medium">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -303,26 +335,56 @@ function App() {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="w-80 p-0 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] bg-white rounded-lg relative overflow-hidden"
|
||||
className="w-80 font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] rounded-lg relative overflow-hidden p-4"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
boxShadow:
|
||||
"1.5px 1.5px 20px 0 rgba(0, 0, 0, 0.65), 1px 1.5px 2px 0 rgba(128, 189, 255, 0.07) inset, -0.5px -1.5px 4px 0 rgba(0, 35, 73, 0.40) inset",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 p-2.5 relative">
|
||||
<div className="text-white text-lg font-semibold ml-2">supermemory</div>
|
||||
<div
|
||||
id="popup-header"
|
||||
className="flex items-center justify-between p-2.5 relative"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 shrink-0 rounded-[3.75px] overflow-hidden relative"
|
||||
style={{ boxShadow: "inset 0px 1px 3.75px 0px #000" }}
|
||||
>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./icon-48.png"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium text-[#737373] leading-normal">
|
||||
{(() => {
|
||||
const name =
|
||||
userData?.name?.split(" ")[0] ||
|
||||
userData?.email?.split("@")[0]
|
||||
if (!name) return "Your"
|
||||
return name.endsWith("s") ? `${name}'` : `${name}'s`
|
||||
})()}
|
||||
</span>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./logo-fullmark.svg"
|
||||
className="h-[14.5px] w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{userSignedIn && (
|
||||
<button
|
||||
className="bg-none border-none text-base cursor-pointer text-gray-500 p-1 rounded transition-colors duration-200"
|
||||
className="bg-transparent border-none cursor-pointer p-1 rounded transition-colors duration-200"
|
||||
onClick={handleSignOut}
|
||||
title="Logout"
|
||||
aria-label="Logout"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
width="19"
|
||||
height="18"
|
||||
viewBox="0 0 19 18"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
|
|
@ -330,15 +392,15 @@ function App() {
|
|||
<path
|
||||
d="M17 9H7.5M15 12L18 9L15 6M10 4V3C10 2.46957 9.78929 1.96086 9.41421 1.58579C9.03914 1.21071 8.53043 1 8 1H3C2.46957 1 1.96086 1.21071 1.58579 1.58579C1.21071 1.96086 1 2.46957 1 3V15C1 15.5304 1.21071 16.0391 1.58579 16.4142C1.96086 16.7893 2.46957 17 3 17H8C8.53043 17 9.03914 16.7893 9.41421 16.4142C9.78929 16.0391 10 15.5304 10 15V14"
|
||||
stroke="#FAFAFA"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 min-h-[250px] pt-1">
|
||||
<div className="min-h-[250px] pt-1">
|
||||
{userSignedIn ? (
|
||||
<div className="text-left">
|
||||
{/* Tab Navigation */}
|
||||
|
|
@ -408,43 +470,119 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Selection */}
|
||||
<div className="mb-0">
|
||||
<button
|
||||
className="w-full bg-[#5B7EF50A] border-none p-4 cursor-pointer text-left rounded-xl flex justify-between items-center transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
onClick={handleShowProjectSelector}
|
||||
type="button"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
}}
|
||||
>
|
||||
{/* Space Selection */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between pl-1">
|
||||
<span className="text-sm font-medium text-[#737373]">
|
||||
Save to project:
|
||||
Save to Space
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-[120px]">
|
||||
{showProjectSelector && (
|
||||
<button
|
||||
id="close-space-selector"
|
||||
className="bg-transparent border-none cursor-pointer p-0 text-[#737373] hover:text-white transition-colors"
|
||||
onClick={() => setShowProjectSelector(false)}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProjectSelector ? (
|
||||
<div className="flex flex-col gap-1 max-h-[180px] overflow-y-auto">
|
||||
{loadingProjects ? (
|
||||
<div className="h-11 flex items-center justify-center text-sm text-[#737373]">
|
||||
Loading spaces...
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<button
|
||||
id={`space-option-${project.id}`}
|
||||
className={`w-full h-11 flex items-center justify-between px-4 rounded-lg bg-transparent border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF510] ${
|
||||
defaultProject?.id === project.id
|
||||
? "bg-[#5B7EF50A]"
|
||||
: ""
|
||||
}`}
|
||||
style={
|
||||
defaultProject?.id === project.id
|
||||
? {
|
||||
boxShadow:
|
||||
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
key={project.id}
|
||||
onClick={() => handleProjectSelect(project)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
|
||||
{project.name}
|
||||
</span>
|
||||
{defaultProject?.id === project.id && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-white shrink-0"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
id="space-selector-trigger"
|
||||
className="w-full h-11 flex items-center justify-between px-4 rounded-lg bg-[#5B7EF50A] border-none cursor-pointer text-left transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
onClick={handleShowProjectSelector}
|
||||
type="button"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-normal text-[rgba(255,255,255,0.94)] overflow-hidden text-ellipsis whitespace-nowrap tracking-tight">
|
||||
{defaultProject
|
||||
? defaultProject.name
|
||||
: "Default Project"}
|
||||
: "Select a space"}
|
||||
</span>
|
||||
<svg
|
||||
aria-label="Select project"
|
||||
className="text-white shrink-0 transition-transform duration-200 hover:text-gray-700 transform rotate-90"
|
||||
fill="none"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
className="text-white shrink-0"
|
||||
>
|
||||
<title>Select project</title>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
<title>Expand</title>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Button at Bottom */}
|
||||
|
|
@ -496,7 +634,7 @@ function App() {
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
|
|
@ -524,34 +662,79 @@ function App() {
|
|||
Import ChatGPT Memories
|
||||
</p>
|
||||
<p className="m-0 text-[14px] text-[#737373] leading-tight">
|
||||
open 'manage', save your memories to supermemory
|
||||
open 'manage' > save your memories to supermemory
|
||||
</p>
|
||||
</div>
|
||||
<RightArrow className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-center justify-center transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
}}
|
||||
onClick={async () => {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
|
||||
const targetUrl = "https://x.com/i/bookmarks"
|
||||
|
||||
if (activeTab?.url === targetUrl) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
const isOnBookmarksPage =
|
||||
activeTab?.url?.includes("x.com/i/bookmarks") ||
|
||||
activeTab?.url?.includes("twitter.com/i/bookmarks")
|
||||
|
||||
if (isOnBookmarksPage && activeTab?.id) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(activeTab.id, {
|
||||
action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL,
|
||||
})
|
||||
} catch (error) {
|
||||
// Content script may not be loaded yet, fall back to intent-based approach
|
||||
console.error(
|
||||
"Failed to send message to content script:",
|
||||
error,
|
||||
)
|
||||
const intentExpiry =
|
||||
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
|
||||
intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const intentExpiry =
|
||||
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
|
||||
intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error opening Twitter import:", error)
|
||||
// Fallback: try to open the bookmarks page anyway
|
||||
try {
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
"Failed to open bookmarks page:",
|
||||
fallbackError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -570,9 +753,10 @@ function App() {
|
|||
Import X/Twitter Bookmarks
|
||||
</p>
|
||||
<p className="m-0 text-[14px] text-[#737373] leading-tight">
|
||||
Click on supermemory on top right to import bookmarks
|
||||
Opens import dialog automatically
|
||||
</p>
|
||||
</div>
|
||||
<RightArrow className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -588,7 +772,9 @@ function App() {
|
|||
</div>
|
||||
) : userData?.email ? (
|
||||
<>
|
||||
<span className="font-medium text-base">Email</span>
|
||||
<span className="font-medium text-base text-white">
|
||||
Email
|
||||
</span>
|
||||
<span
|
||||
className="text-sm text-[#525966] p-3 rounded-xl bg-[#5B7EF50A]"
|
||||
style={{
|
||||
|
|
@ -608,7 +794,7 @@ function App() {
|
|||
|
||||
{/* Chat Integration Section */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-semibold mb-3">
|
||||
<h3 className="text-base font-semibold mb-3 text-white">
|
||||
Chat Integration
|
||||
</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-[#5B7EF50A] mb-3">
|
||||
|
|
@ -654,53 +840,6 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProjectSelector && (
|
||||
<div className="absolute inset-0 bg-white rounded-lg z-1000 shadow-xl flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b border-gray-200 text-base font-semibold text-black shrink-0">
|
||||
<span>Select the Project</span>
|
||||
<button
|
||||
className="bg-transparent border-none text-xl cursor-pointer text-gray-500 p-0 w-6 h-6 flex items-center justify-center hover:text-black"
|
||||
onClick={() => setShowProjectSelector(false)}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{loadingProjects ? (
|
||||
<div className="py-8 px-4 text-center text-gray-500 text-sm">
|
||||
Loading projects...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
className={`flex justify-between items-center py-3 px-4 cursor-pointer transition-colors duration-200 border-b border-gray-100 bg-transparent border-none w-full text-left last:border-b-0 hover:bg-gray-50 ${
|
||||
defaultProject?.id === project.id ? "bg-blue-50" : ""
|
||||
}`}
|
||||
key={project.id}
|
||||
onClick={() => handleProjectSelect(project)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-col flex-1 gap-0.5">
|
||||
<span className="text-sm font-medium text-black wrap-break-word leading-tight">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{project.documentCount} docs
|
||||
</span>
|
||||
</div>
|
||||
{defaultProject?.id === project.id && (
|
||||
<span className="text-blue-600 font-bold text-base">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-2">
|
||||
|
|
@ -718,18 +857,18 @@ function App() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="mb-8">
|
||||
<h2 className="m-0 mb-4 text-sm font-normal text-black leading-tight">
|
||||
<h2 className="m-0 mb-4 text-sm font-normal leading-tight">
|
||||
Login to unlock all chrome extension features
|
||||
</h2>
|
||||
|
||||
<ul className="list-none p-0 m-0 text-left">
|
||||
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">
|
||||
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] before:font-bold">
|
||||
Save any page to your supermemory
|
||||
</li>
|
||||
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">
|
||||
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] before:font-bold">
|
||||
Import all your Twitter / X Bookmarks
|
||||
</li>
|
||||
<li className="py-1.5 text-sm text-black relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-black before:font-bold">
|
||||
<li className="py-1.5 text-sm text-[#737373] relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-[#737373] before:font-bold">
|
||||
Import your ChatGPT Memories
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -737,7 +876,7 @@ function App() {
|
|||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="m-0 mb-4 text-sm text-gray-500">
|
||||
<p className="m-0 mb-4 text-sm text-[#737373]">
|
||||
Having trouble logging in?{" "}
|
||||
<button
|
||||
className="bg-transparent border-none text-blue-500 cursor-pointer underline text-sm p-0 hover:text-blue-700"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
border: 1px solid #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
|
@ -18,7 +19,6 @@
|
|||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 12 KiB |
15
apps/browser-extension/public/logo-fullmark.svg
Normal file
15
apps/browser-extension/public/logo-fullmark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7 KiB |
|
|
@ -114,7 +114,7 @@ export async function validateAuthToken(): Promise<boolean> {
|
|||
/**
|
||||
* Get user data from storage
|
||||
*/
|
||||
export async function getUserData(): Promise<{ email?: string } | null> {
|
||||
export async function getUserData(): Promise<{ email?: string; name?: string } | null> {
|
||||
try {
|
||||
return (await userData.getValue()) || null
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const API_ENDPOINTS = {
|
|||
*/
|
||||
export const ELEMENT_IDS = {
|
||||
TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
|
||||
TWITTER_ONBOARDING_TOAST: "sm-twitter-onboarding-toast",
|
||||
TWITTER_IMPORT_PROGRESS_TOAST: "sm-twitter-import-progress-toast",
|
||||
SUPERMEMORY_TOAST: "sm-toast",
|
||||
SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
|
||||
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
|
||||
|
|
@ -26,12 +28,22 @@ export const ELEMENT_IDS = {
|
|||
SELECTION_SEARCH_PANEL: "sm-selection-search-panel",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Storage Keys for local
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
TWITTER_BOOKMARKS_ONBOARDING_SEEN: "sm_twitter_bookmarks_onboarding_seen",
|
||||
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL: "sm_twitter_bookmarks_import_intent_until",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* UI Configuration
|
||||
*/
|
||||
export const UI_CONFIG = {
|
||||
BUTTON_SHOW_DELAY: 2000, // milliseconds
|
||||
TOAST_DURATION: 3000, // milliseconds
|
||||
ONBOARDING_TOAST_DURATION: 6000, // milliseconds (6 seconds for progress bar)
|
||||
IMPORT_INTENT_TTL: 2 * 60 * 1000, // 2 minutes TTL for import intent
|
||||
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
|
||||
PAGINATION_DELAY: 1000, // 1 second between requests
|
||||
AUTO_SEARCH_DEBOUNCE_DELAY: 1500, // milliseconds to wait after user stops typing
|
||||
|
|
@ -75,6 +87,7 @@ export const MESSAGE_TYPES = {
|
|||
FETCH_PROJECTS: "sm-fetch-projects",
|
||||
SEARCH_SELECTION: "sm-search-selection",
|
||||
OPEN_SEARCH_PANEL: "sm-open-search-panel",
|
||||
TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal",
|
||||
} as const
|
||||
|
||||
export const CONTEXT_MENU_IDS = {
|
||||
|
|
|
|||
|
|
@ -545,10 +545,10 @@ export function createProjectSelectionModal(
|
|||
importButton.textContent = "Import"
|
||||
importButton.style.cssText = `
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #d1d5db;
|
||||
color: #9ca3af;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -577,10 +577,10 @@ export function createProjectSelectionModal(
|
|||
importButton.disabled = true
|
||||
importButton.style.cssText = `
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #d1d5db;
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export default defineConfig({
|
|||
manifest: {
|
||||
name: "supermemory",
|
||||
homepage_url: "https://supermemory.ai",
|
||||
version: "6.0.105",
|
||||
version: "6.1.0",
|
||||
permissions: ["contextMenus", "storage", "activeTab", "webRequest", "tabs"],
|
||||
host_permissions: [
|
||||
"*://x.com/*",
|
||||
|
|
|
|||
294
apps/docs/install.md
Normal file
294
apps/docs/install.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
You are integrating Supermemory into my application. Supermemory provides user memory, semantic search, and automatic knowledge extraction for AI applications.
|
||||
|
||||
You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
|
||||
|
||||
## STEP 1: ASK ME THESE QUESTIONS
|
||||
|
||||
1. What are you building?
|
||||
- Personal chatbot/assistant
|
||||
- Team knowledge base
|
||||
- Customer support bot
|
||||
- Document Q&A
|
||||
- Other
|
||||
|
||||
2. How do you want to integrate?
|
||||
- Vercel AI SDK (@supermemory/tools)
|
||||
- OpenAI plugins
|
||||
- Direct SDK (supermemory npm/pip)
|
||||
- Direct API calls
|
||||
|
||||
3. Data model?
|
||||
- Individual users only → containerTag: userId
|
||||
- Organizations only → containerTag: orgId
|
||||
- Both users AND orgs → ask for strategy
|
||||
|
||||
4. Do you want USER PROFILES?
|
||||
User profiles are automatically-maintained facts about users (what they like, what they're working on, preferences).
|
||||
- Yes (RECOMMENDED) → Use client.profile() to get context
|
||||
- No → Just use search
|
||||
|
||||
5. How should I retrieve context?
|
||||
- OPTION A: One call with search included → profile({ containerTag, q: userMessage })
|
||||
- OPTION B: Separate calls → profile() for facts, search() for memories
|
||||
|
||||
## STEP 2: INSTALL
|
||||
|
||||
```bash
|
||||
# Get API key: https://console.supermemory.ai
|
||||
npm install supermemory # or: pip install supermemory
|
||||
# For Vercel AI SDK: npm install @supermemory/tools
|
||||
export SUPERMEMORY_API_KEY="sm_..."
|
||||
```
|
||||
|
||||
## STEP 3: CONFIGURE SETTINGS (DO THIS FIRST)
|
||||
|
||||
```typescript
|
||||
// PATCH https://api.supermemory.ai/v3/settings
|
||||
fetch('https://api.supermemory.ai/v3/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
body: JSON.stringify({
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].`
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## STEP 4: CONTAINER TAG STRATEGY
|
||||
|
||||
Based on their data model answer:
|
||||
|
||||
**USER-ONLY APP:**
|
||||
|
||||
```typescript
|
||||
containerTag: userId
|
||||
```
|
||||
|
||||
**ORG-ONLY APP:**
|
||||
|
||||
```typescript
|
||||
containerTag: orgId // Org members share memories
|
||||
```
|
||||
|
||||
**BOTH (ask which):**
|
||||
- Option A: `containerTag: \`\${userId}-\${orgId}\``
|
||||
- Option B: `containerTag: orgId, metadata: { userId }`
|
||||
- Option C: `containerTag: userId, metadata: { orgId }`
|
||||
|
||||
## STEP 5: INTEGRATION CODE
|
||||
|
||||
Based on their integration choice:
|
||||
|
||||
### VERCEL AI SDK
|
||||
|
||||
```typescript
|
||||
import { streamText } from 'ai'
|
||||
import { anthropic } from '@ai-sdk/anthropic'
|
||||
import { supermemoryTools } from '@supermemory/tools/ai-sdk'
|
||||
|
||||
// Option 1: Agent tools (recommended for agentic flows)
|
||||
const result = await streamText({
|
||||
model: anthropic('claude-3-5-sonnet-20241022'),
|
||||
prompt: userMessage,
|
||||
tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, {
|
||||
containerTags: [userId]
|
||||
})
|
||||
})
|
||||
// Agent gets searchMemories, addMemory, fetchMemory tools
|
||||
|
||||
// Option 2: Profile middleware (automatic context injection)
|
||||
import { withSupermemory } from '@supermemory/tools/ai-sdk'
|
||||
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), userId)
|
||||
|
||||
const result = await generateText({
|
||||
model: modelWithMemory,
|
||||
messages: [{ role: 'user', content: userMessage }]
|
||||
})
|
||||
// Profile is automatically injected into context
|
||||
```
|
||||
|
||||
### DIRECT SDK (WITH PROFILES)
|
||||
|
||||
```typescript
|
||||
import Supermemory from 'supermemory'
|
||||
|
||||
const client = new Supermemory()
|
||||
|
||||
// Before each LLM call:
|
||||
const { profile, searchResults } = await client.profile({
|
||||
containerTag: userId,
|
||||
q: userMessage // Include this if they chose OPTION A (one call)
|
||||
// Omit if they chose OPTION B (separate calls)
|
||||
})
|
||||
|
||||
// Build context
|
||||
const context = `
|
||||
Static facts: ${profile.static.join('\n')}
|
||||
Recent context: ${profile.dynamic.join('\n')}
|
||||
${searchResults ? `Memories: ${searchResults.results.map(r => r.content).join('\n')}` : ''}
|
||||
`
|
||||
|
||||
// Send to LLM
|
||||
const messages = [
|
||||
{ role: 'system', content: `User context:\n${context}` },
|
||||
{ role: 'user', content: userMessage }
|
||||
]
|
||||
|
||||
// After LLM responds:
|
||||
await client.memories.add({
|
||||
content: `user: ${userMessage}\nassistant: ${response}`,
|
||||
containerTag: userId
|
||||
})
|
||||
```
|
||||
|
||||
### DIRECT SDK (NO PROFILES)
|
||||
|
||||
```typescript
|
||||
import Supermemory from 'supermemory'
|
||||
|
||||
const client = new Supermemory()
|
||||
|
||||
// Search for relevant memories
|
||||
const results = await client.search({
|
||||
q: userMessage,
|
||||
containerTag: userId,
|
||||
searchMode: 'hybrid', // Searches memories + document chunks
|
||||
limit: 5
|
||||
})
|
||||
|
||||
// Build context
|
||||
const context = results.results.map(r => r.content).join('\n')
|
||||
|
||||
// Send to LLM with context
|
||||
const messages = [
|
||||
{ role: 'system', content: `Relevant context:\n${context}` },
|
||||
{ role: 'user', content: userMessage }
|
||||
]
|
||||
|
||||
// Store the conversation
|
||||
await client.memories.add({
|
||||
content: `user: ${userMessage}\nassistant: ${response}`,
|
||||
containerTag: userId
|
||||
})
|
||||
```
|
||||
|
||||
### PYTHON VERSION
|
||||
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory()
|
||||
|
||||
# With profiles (if they want it)
|
||||
profile_data = client.profile(
|
||||
container_tag=user_id,
|
||||
q=user_message # Include if OPTION A, omit if OPTION B
|
||||
)
|
||||
|
||||
context = f"""
|
||||
Static: {chr(10).join(profile_data.profile.static)}
|
||||
Dynamic: {chr(10).join(profile_data.profile.dynamic)}
|
||||
"""
|
||||
|
||||
# Store conversation
|
||||
client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id)
|
||||
```
|
||||
|
||||
### DIRECT API
|
||||
|
||||
```bash
|
||||
# Add memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"content": "conversation", "containerTag": "userId"}'
|
||||
|
||||
# Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"containerTag": "userId", "q": "search query"}'
|
||||
|
||||
# Search
|
||||
curl -X POST https://api.supermemory.ai/v4/search \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}'
|
||||
```
|
||||
|
||||
## STEP 6: FILE UPLOADS (if they need it)
|
||||
|
||||
```typescript
|
||||
// Files are automatically extracted (PDFs, images with OCR, videos with transcription)
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileBlob)
|
||||
formData.append('containerTag', userId)
|
||||
|
||||
await fetch('https://api.supermemory.ai/v3/documents/file', {
|
||||
method: 'POST',
|
||||
headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY },
|
||||
body: formData
|
||||
})
|
||||
|
||||
// Processing is async - check status before assuming searchable
|
||||
// GET /v3/documents/{documentId}
|
||||
```
|
||||
|
||||
## STEP 7: SEARCH MODES
|
||||
|
||||
```typescript
|
||||
// HYBRID (recommended) - searches memories + document chunks
|
||||
searchMode: 'hybrid'
|
||||
|
||||
// MEMORIES ONLY - just extracted memories, no original text
|
||||
searchMode: 'memories'
|
||||
```
|
||||
|
||||
## STEP 8: METADATA FILTERS (if they need secondary filtering)
|
||||
|
||||
```typescript
|
||||
await client.search({
|
||||
q: query,
|
||||
containerTag: userId,
|
||||
filters: {
|
||||
AND: [
|
||||
{ key: 'type', value: 'conversation', type: 'string_equal' },
|
||||
{ key: 'timestamp', value: '2024', type: 'string_contains' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## KEY POINTS:
|
||||
|
||||
1. Configure settings FIRST with filterPrompt
|
||||
2. User profiles = automatic facts about users (profile.static + profile.dynamic)
|
||||
3. profile({ containerTag, q }) combines profile + search in ONE call
|
||||
4. Search modes: 'hybrid' (recommended) or 'memories'
|
||||
5. File extraction is automatic - no config needed
|
||||
6. Store conversations after each interaction
|
||||
7. containerTag should match what you put in filterPrompt
|
||||
|
||||
## TESTING:
|
||||
|
||||
```bash
|
||||
# 1. Configure settings
|
||||
curl -X PATCH https://api.supermemory.ai/v3/settings \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"shouldLLMFilter": true, "filterPrompt": "..."}'
|
||||
|
||||
# 2. Add test memory
|
||||
curl -X POST https://api.supermemory.ai/v3/documents \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"content": "Test", "containerTag": "test_user"}'
|
||||
|
||||
# 3. Get profile
|
||||
curl -X POST https://api.supermemory.ai/v4/profile \
|
||||
-H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \
|
||||
-d '{"containerTag": "test_user"}'
|
||||
```
|
||||
|
||||
## NOW:
|
||||
|
||||
1. Ask me the 5 questions above
|
||||
2. Generate complete working code based on my answers
|
||||
3. Include installation, settings config, and full integration
|
||||
|
||||
**DOCS:** https://supermemory.ai/docs
|
||||
|
|
@ -9,8 +9,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"agents": "^0.2.32",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"agents": "^0.3.5",
|
||||
"hono": "^4.11.1",
|
||||
"posthog-node": "^5.18.0",
|
||||
"supermemory": "^4.0.0",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { Hono } from "hono"
|
||||
import { SupermemoryMCP } from "./server"
|
||||
import { isApiKey, validateApiKey, validateOAuthToken } from "./auth"
|
||||
import { initPosthog } from "./posthog"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
|
||||
type Bindings = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
|
|
@ -78,7 +79,7 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|||
if (!response.ok) {
|
||||
return c.json(
|
||||
{ error: "Failed to fetch authorization server metadata" },
|
||||
response.status,
|
||||
{ status: response.status as ContentfulStatusCode },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type Props = {
|
|||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTags: string[] = []
|
||||
|
||||
server = new McpServer({
|
||||
name: "supermemory",
|
||||
|
|
@ -37,7 +38,6 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
|
||||
initPosthog(this.env.POSTHOG_API_KEY)
|
||||
|
||||
|
||||
// Hook MCP McpAgent to capture client info
|
||||
this.server.server.oninitialized = async () => {
|
||||
const clientVersion = this.server.server.getClientVersion()
|
||||
|
|
@ -49,6 +49,11 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
await this.ctx.storage.put("clientInfo", this.clientInfo)
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshContainerTags() // Fetch available projects for schema descriptions
|
||||
|
||||
const containerTagDescription = this.getContainerTagDescription()
|
||||
|
||||
const memorySchema = z.object({
|
||||
content: z
|
||||
.string()
|
||||
|
|
@ -58,7 +63,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.describe("Optional container tag")
|
||||
.describe(containerTagDescription)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
|
|
@ -71,7 +76,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.describe("Optional container tag")
|
||||
.describe(containerTagDescription)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
|
|
@ -79,7 +84,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.describe("Optional container tag to scope the profile")
|
||||
.describe(containerTagDescription)
|
||||
.optional(),
|
||||
includeRecent: z
|
||||
.boolean()
|
||||
|
|
@ -176,6 +181,63 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
},
|
||||
)
|
||||
|
||||
// Register listProjects tool
|
||||
this.server.registerTool(
|
||||
"listProjects",
|
||||
{
|
||||
description:
|
||||
"List all available projects for organizing memories. Use this to discover valid project names for memory/recall operations.",
|
||||
inputSchema: z.object({
|
||||
refresh: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe("Refresh the list from the server (default: true)"),
|
||||
}),
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: { refresh?: boolean }) => {
|
||||
try {
|
||||
if (args.refresh !== false) {
|
||||
await this.refreshContainerTags()
|
||||
}
|
||||
const projects = this.cachedContainerTags
|
||||
|
||||
if (projects.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "No projects found. Memories will use the default project.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Available projects:\n${projects.map((p) => `- ${p}`).join("\n")}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error listing projects: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Register whoAmI tool
|
||||
this.server.registerTool(
|
||||
"whoAmI",
|
||||
|
|
@ -222,6 +284,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
"User profile and preferences for system context injection. Returns a formatted system message with user's stable preferences and recent activity.",
|
||||
//argsSchema: contextPromptSchema.shape, TODO: commenting out for now as it will add more friction to the user
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: ContextPromptArgs) => {
|
||||
try {
|
||||
const { containerTag, includeRecent = true } = args
|
||||
|
|
@ -543,4 +606,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
private getMcpSessionId(): string {
|
||||
return this.ctx.id.name || "unknown"
|
||||
}
|
||||
|
||||
private async refreshContainerTags(): Promise<void> {
|
||||
try {
|
||||
const client = this.getClient()
|
||||
this.cachedContainerTags = await client.getProjects()
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch container tags:", error)
|
||||
}
|
||||
}
|
||||
|
||||
private getContainerTagDescription(): string {
|
||||
const baseDescription = "Optional project to scope memories"
|
||||
if (this.cachedContainerTags.length === 0) {
|
||||
return baseDescription
|
||||
}
|
||||
return `${baseDescription}. Available projects: ${this.cachedContainerTags.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue