mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
chore(ci): harden Python SDK test workflow
This commit is contained in:
commit
006a3f5500
91 changed files with 4331 additions and 1417 deletions
208
.github/workflows/ci-python.yml
vendored
208
.github/workflows/ci-python.yml
vendored
|
|
@ -9,97 +9,231 @@ on:
|
|||
- "packages/pipecat-sdk-python/**"
|
||||
- ".github/workflows/ci-python.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
|
||||
jobs:
|
||||
agent-framework-python:
|
||||
name: agent-framework-python
|
||||
name: agent-framework-python (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.13"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/agent-framework-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/agent-framework-python/pyproject.toml
|
||||
|
||||
- name: Install package and test dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio
|
||||
- name: Install build and test tools
|
||||
run: python -m pip install build pytest pytest-asyncio
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel and runtime dependencies
|
||||
run: python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: python -m pip check
|
||||
|
||||
- name: Verify installed wheel
|
||||
run: >-
|
||||
python -c "from pathlib import Path; import supermemory_agent_framework;
|
||||
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
run: python -m pytest
|
||||
|
||||
openai-sdk-python:
|
||||
name: openai-sdk-python
|
||||
name: openai-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.9"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-version: "3.50.0"
|
||||
expected-supermemory-version: "3.50.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: locked
|
||||
supermemory-version: ""
|
||||
expected-supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/openai-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install package and test dependencies
|
||||
run: |
|
||||
pip install -e .
|
||||
pip install pytest pytest-asyncio python-dotenv
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: "0.12.5"
|
||||
enable-cache: true
|
||||
working-directory: packages/openai-sdk-python
|
||||
cache-dependency-glob: uv.lock
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
- name: Install locked dependencies
|
||||
run: uv sync --locked --python "${{ matrix.python-version }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: uv build --wheel --out-dir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install built wheel
|
||||
run: >-
|
||||
uv pip install --python .venv/bin/python --reinstall --no-deps
|
||||
"$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Install minimum Supermemory SDK
|
||||
if: matrix.supermemory-version != ''
|
||||
run: >-
|
||||
uv pip install --python .venv/bin/python
|
||||
"supermemory==${{ matrix.supermemory-version }}"
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: uv pip check --python .venv/bin/python
|
||||
|
||||
- name: Verify installed wheel and SDK version
|
||||
run: >-
|
||||
.venv/bin/python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import supermemory_openai;
|
||||
assert version('supermemory') == '${{ matrix.expected-supermemory-version }}';
|
||||
assert 'site-packages' in Path(supermemory_openai.__file__).parts"
|
||||
|
||||
- name: Run tests without changing the verified environment
|
||||
run: .venv/bin/python -m pytest
|
||||
|
||||
cartesia-sdk-python:
|
||||
name: cartesia-sdk-python
|
||||
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-spec: "supermemory==3.16.0"
|
||||
supermemory-version: "3.16.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-supermemory
|
||||
supermemory-spec: "supermemory==3.59.0"
|
||||
supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/cartesia-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
|
||||
|
||||
# Suite stubs cartesia-line / loguru / pydantic at import time, so it
|
||||
# runs against sources with nothing installed.
|
||||
- name: Run tests
|
||||
run: PYTHONPATH=src python -m unittest discover -s tests -v
|
||||
- name: Install build tools and lightweight test dependencies
|
||||
run: >-
|
||||
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
|
||||
"${{ matrix.supermemory-spec }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel without the voice framework
|
||||
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Run tests against the installed wheel and real lightweight dependencies
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
import supermemory_cartesia;
|
||||
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
|
||||
raise SystemExit(result)"
|
||||
|
||||
pipecat-sdk-python:
|
||||
name: pipecat-sdk-python
|
||||
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-spec: "supermemory==3.16.0"
|
||||
supermemory-version: "3.16.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-supermemory
|
||||
supermemory-spec: "supermemory==3.59.0"
|
||||
supermemory-version: "3.59.0"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/pipecat-sdk-python
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
|
||||
|
||||
# Suite stubs pipecat-ai / loguru / pydantic at import time, so it
|
||||
# runs against sources with nothing installed.
|
||||
- name: Run tests
|
||||
run: PYTHONPATH=src python -m unittest discover -s tests -v
|
||||
- name: Install build tools and lightweight test dependencies
|
||||
run: >-
|
||||
python -m pip install build pytest "loguru>=0.7.3" "pydantic>=2.10.0"
|
||||
"${{ matrix.supermemory-spec }}"
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel without the voice framework
|
||||
run: python -m pip install --no-deps "$RUNNER_TEMP"/wheels/*.whl
|
||||
|
||||
- name: Run tests against the installed wheel and real lightweight dependencies
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import loguru, pydantic, supermemory, pytest;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
import supermemory_pipecat;
|
||||
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
|
||||
raise SystemExit(result)"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
|
||||
if (response?.success && response?.data && input) {
|
||||
const memoryText = showMemorySuggestion("gemini", input, response.data)
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
iconElement.dataset.supermemories = memoryText
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,46 @@ export function buildSupermemoryText(memories: unknown): string {
|
|||
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
|
||||
}
|
||||
|
||||
function normalizeMemoryList(memories: unknown): string[] {
|
||||
const list = Array.isArray(memories)
|
||||
? memories
|
||||
: memories == null
|
||||
? []
|
||||
: [memories]
|
||||
return list
|
||||
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0)
|
||||
}
|
||||
|
||||
export function serializeMemoriesForDataset(memories: unknown): string {
|
||||
const list = normalizeMemoryList(memories)
|
||||
return list.length > 0 ? JSON.stringify(list) : ""
|
||||
}
|
||||
|
||||
export function parseMemoriesFromDataset(
|
||||
raw: string | null | undefined,
|
||||
): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
|
||||
} catch {
|
||||
// Not JSON — fall through to the legacy delimiter split.
|
||||
}
|
||||
return raw
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
}
|
||||
|
||||
export function renumberIncludedMemories(memories: string[]): string[] {
|
||||
return memories.map((memory, index) => {
|
||||
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
|
||||
return `${index + 1}. ${text} \n`
|
||||
})
|
||||
}
|
||||
|
||||
export function showMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
|
|
@ -305,10 +345,7 @@ export function showMarkerPopover(
|
|||
color: rgba(255, 255, 255, 0.76);
|
||||
`
|
||||
|
||||
memories
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
parseMemoriesFromDataset(memories)
|
||||
.slice(0, 5)
|
||||
.forEach((memory) => {
|
||||
const item = document.createElement("div")
|
||||
|
|
|
|||
|
|
@ -10,11 +10,30 @@ import {
|
|||
autoCapturePromptsEnabled,
|
||||
} from "../../utils/storage"
|
||||
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
|
||||
import {
|
||||
buildSupermemoryText,
|
||||
parseMemoriesFromDataset,
|
||||
renumberIncludedMemories,
|
||||
serializeMemoriesForDataset,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
let t3DebounceTimeout: NodeJS.Timeout | null = null
|
||||
let t3RouteObserver: MutationObserver | null = null
|
||||
let t3UrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let t3ObserverThrottle: NodeJS.Timeout | null = null
|
||||
let t3IncludedPopup: {
|
||||
el: HTMLElement
|
||||
onClick: (event: MouseEvent) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} | null = null
|
||||
|
||||
function disposeT3IncludedPopup() {
|
||||
if (!t3IncludedPopup) return
|
||||
document.removeEventListener("click", t3IncludedPopup.onClick)
|
||||
clearTimeout(t3IncludedPopup.timer)
|
||||
t3IncludedPopup.el.remove()
|
||||
t3IncludedPopup = null
|
||||
}
|
||||
|
||||
export function initializeT3() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
|
||||
|
|
@ -53,6 +72,7 @@ function setupT3RouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
disposeT3IncludedPopup()
|
||||
currentUrl = window.location.href
|
||||
setTimeout(() => {
|
||||
addSupermemoryIconToT3Input()
|
||||
|
|
@ -231,9 +251,13 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
textareaElement.dataset.supermemories = buildSupermemoryText(
|
||||
response.data,
|
||||
)
|
||||
|
||||
iconElement.dataset.memoriesData = response.data
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
updateT3IconFeedback("Included Memories", iconElement)
|
||||
} else {
|
||||
|
|
@ -268,6 +292,8 @@ function updateT3IconFeedback(
|
|||
iconElement.dataset.originalHtml = iconElement.innerHTML
|
||||
}
|
||||
|
||||
disposeT3IncludedPopup()
|
||||
|
||||
const feedbackDiv = document.createElement("div")
|
||||
feedbackDiv.style.cssText = `
|
||||
display: flex;
|
||||
|
|
@ -329,11 +355,9 @@ function updateT3IconFeedback(
|
|||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const memoriesText = iconElement.dataset.memoriesData || ""
|
||||
const individualMemories = memoriesText
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
const individualMemories = parseMemoriesFromDataset(
|
||||
iconElement.dataset.memoriesData,
|
||||
)
|
||||
|
||||
individualMemories.forEach((memory, index) => {
|
||||
const memoryItem = document.createElement("div")
|
||||
|
|
@ -405,66 +429,65 @@ function updateT3IconFeedback(
|
|||
popup.style.display = "block"
|
||||
})
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.style.display = "none"
|
||||
}
|
||||
})
|
||||
}
|
||||
document.addEventListener("click", onClick)
|
||||
t3IncludedPopup = {
|
||||
el: popup,
|
||||
onClick,
|
||||
timer: setTimeout(disposeT3IncludedPopup, 300000),
|
||||
}
|
||||
|
||||
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
|
||||
const htmlButton = button as HTMLButtonElement
|
||||
htmlButton.addEventListener("click", () => {
|
||||
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
|
||||
const memoryItem = htmlButton.parentElement
|
||||
htmlButton.parentElement?.remove()
|
||||
|
||||
if (memoryItem) {
|
||||
content.removeChild(memoryItem)
|
||||
}
|
||||
|
||||
const currentMemories = (iconElement.dataset.memoriesData || "")
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
currentMemories.splice(index, 1)
|
||||
|
||||
const updatedMemories = currentMemories.join(" ,")
|
||||
|
||||
iconElement.dataset.memoriesData = updatedMemories
|
||||
const remainingMemories = parseMemoriesFromDataset(
|
||||
iconElement.dataset.memoriesData,
|
||||
)
|
||||
remainingMemories.splice(index, 1)
|
||||
const remaining = renumberIncludedMemories(remainingMemories)
|
||||
|
||||
const textareaElement =
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
||||
|
||||
// Only wipe when nothing remains — `<= 1` used to discard the last kept memory.
|
||||
if (remaining.length === 0) {
|
||||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
disposeT3IncludedPopup()
|
||||
return
|
||||
}
|
||||
|
||||
iconElement.dataset.memoriesData =
|
||||
serializeMemoriesForDataset(remaining)
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
textareaElement.dataset.supermemories =
|
||||
buildSupermemoryText(remaining)
|
||||
}
|
||||
|
||||
content
|
||||
.querySelectorAll("button[data-memory-index]")
|
||||
.forEach((btn, newIndex) => {
|
||||
const htmlBtn = btn as HTMLButtonElement
|
||||
htmlBtn.dataset.memoryIndex = newIndex.toString()
|
||||
htmlBtn.dataset.memoryIndex = String(newIndex)
|
||||
const label = htmlBtn.previousElementSibling
|
||||
if (label) {
|
||||
label.textContent = remaining[newIndex].trim()
|
||||
}
|
||||
})
|
||||
|
||||
if (currentMemories.length <= 1) {
|
||||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
delete iconElement.dataset.memoriesData
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
popup.style.display = "none"
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}, 300000)
|
||||
}
|
||||
|
||||
iconElement.innerHTML = ""
|
||||
|
|
@ -556,6 +579,7 @@ function setupT3PromptCapture() {
|
|||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
disposeT3IncludedPopup()
|
||||
}
|
||||
|
||||
const handleT3SendButtonClick = async (event: Event) => {
|
||||
|
|
@ -711,6 +735,7 @@ async function setupT3AutoFetch() {
|
|||
if (textareaElement.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
disposeT3IncludedPopup()
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"check-types": "wxt prepare && tsc --noEmit",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@
|
|||
{
|
||||
"group": "Other resources",
|
||||
"pages": [
|
||||
{
|
||||
"group": "General",
|
||||
"icon": "book-open",
|
||||
"pages": ["ingestion/batch-ingest-historical-data"]
|
||||
},
|
||||
{
|
||||
"group": "Benchmarking",
|
||||
"icon": "flask-conical",
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ console.log(doc.status); // "queued" | "processing" | "done"
|
|||
|
||||
## Next Steps
|
||||
|
||||
- [How to backfill historical data](/ingestion/batch-ingest-historical-data) — Import dated content with the batch API
|
||||
- [Search Memories](/recall/search) — Query your content
|
||||
- [User Profiles](/recall/user-profiles) — Get user context
|
||||
- [Organizing & Filtering](/concepts/filtering) — Container tags and metadata
|
||||
|
|
|
|||
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal file
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
---
|
||||
title: "How to backfill historical data into Supermemory"
|
||||
sidebarTitle: "Backfill historical data"
|
||||
description: "Backfill historical documents into Supermemory with documentDate, stable custom IDs, and the batch ingestion API."
|
||||
icon: "history"
|
||||
---
|
||||
|
||||
Use `POST /v3/documents/batch` to backfill exports, emails, messages, or other dated records.
|
||||
|
||||
<Warning>
|
||||
Sort the source data oldest to newest, add `documentDate` to every document.
|
||||
</Warning>
|
||||
|
||||
## Backfill in batches
|
||||
|
||||
Backfill dated content by setting `documentDate` on each document, sorting the source records oldest to newest, and sending them in batches. Each request can contain up to 600 documents.
|
||||
|
||||
**Endpoint:** [`POST /v3/documents/batch`](/api-reference/ingest/batch-add-documents)
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript TypeScript
|
||||
import Supermemory from "supermemory";
|
||||
|
||||
type SourceDocument = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const client = new Supermemory();
|
||||
const batchSize = 100;
|
||||
|
||||
async function backfillHistoricalData(sourceDocuments: SourceDocument[]) {
|
||||
const documents = sourceDocuments
|
||||
.map((document) => ({
|
||||
content: document.content,
|
||||
customId: document.id,
|
||||
documentDate: new Date(document.createdAt).toISOString()
|
||||
}))
|
||||
.sort((a, b) => a.documentDate.localeCompare(b.documentDate));
|
||||
|
||||
for (let offset = 0; offset < documents.length; offset += batchSize) {
|
||||
const result = await client.documents.batchAdd({
|
||||
containerTag: "historical_import",
|
||||
documents: documents.slice(offset, offset + batchSize)
|
||||
});
|
||||
|
||||
if (result.failed > 0) {
|
||||
throw new Error(`${result.failed} documents failed to ingest`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python Python
|
||||
from datetime import datetime, timezone
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory()
|
||||
batch_size = 100
|
||||
|
||||
def to_utc(value: str) -> str:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("created_at must include a timezone")
|
||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def backfill_historical_data(source_documents: list[dict[str, str]]) -> None:
|
||||
documents = sorted(
|
||||
[
|
||||
{
|
||||
"content": document["content"],
|
||||
"custom_id": document["id"],
|
||||
"document_date": to_utc(document["created_at"]),
|
||||
}
|
||||
for document in source_documents
|
||||
],
|
||||
key=lambda document: document["document_date"],
|
||||
)
|
||||
|
||||
for offset in range(0, len(documents), batch_size):
|
||||
result = client.documents.batch_add(
|
||||
container_tag="historical_import",
|
||||
documents=documents[offset : offset + batch_size],
|
||||
)
|
||||
|
||||
if result.failed > 0:
|
||||
raise RuntimeError(f"{result.failed} documents failed to ingest")
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Optional: wait for processing to finish
|
||||
|
||||
**Endpoint:** [`GET /v3/documents/{id}`](/api-reference/documents/get-document)
|
||||
|
||||
The batch endpoint returns after accepting the documents. If a later step depends on completed memory generation, poll the returned document IDs until both `status` and `dreamingStatus` are `done`.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript TypeScript
|
||||
async function waitUntilDone(ids: string[]) {
|
||||
while (true) {
|
||||
const documents = await Promise.all(
|
||||
ids.map((id) => client.documents.get(id))
|
||||
);
|
||||
|
||||
if (documents.some((document) => document.status === "failed")) {
|
||||
throw new Error("A document failed to process");
|
||||
}
|
||||
|
||||
if (
|
||||
documents.every(
|
||||
(document) =>
|
||||
document.status === "done" && document.dreamingStatus === "done"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python Python
|
||||
import time
|
||||
|
||||
def wait_until_done(ids: list[str]) -> None:
|
||||
while True:
|
||||
documents = [client.documents.get(document_id) for document_id in ids]
|
||||
|
||||
if any(document.status == "failed" for document in documents):
|
||||
raise RuntimeError("A document failed to process")
|
||||
|
||||
if all(
|
||||
document.status == "done" and document.dreaming_status == "done"
|
||||
for document in documents
|
||||
):
|
||||
return
|
||||
|
||||
time.sleep(10)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
|
@ -27,6 +27,28 @@ bunx supermemory local
|
|||
|
||||
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
|
||||
|
||||
### Pin or change versions
|
||||
|
||||
Pass an explicit version to install (or roll back to) a specific release instead of `latest`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash -s -- 0.0.3
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Before rolling back, back up your [data directory](#where-things-live). The installer replaces the binary, but an older server may not understand data or schema changes made by a newer release.
|
||||
</Warning>
|
||||
|
||||
Release tags are `server-v<version>` on [GitHub Releases](https://github.com/supermemoryai/supermemory/releases) (for example [`server-v0.0.3`](https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.3)).
|
||||
|
||||
To move to the newest release later:
|
||||
|
||||
```bash
|
||||
supermemory-server upgrade
|
||||
```
|
||||
|
||||
The binary may also print an “update available” notification on startup. If you intentionally pinned an older version (for example while debugging a regression), you can ignore that message until you are ready to upgrade.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi
|
|||
|
||||
<JourneyStep number="2" title="Ingest — get content in">
|
||||
<JourneyItem icon="plus" title="Add memories & documents" href="/ingestion/add-memories" />
|
||||
<JourneyItem icon="history" title="Backfill historical data" href="/ingestion/batch-ingest-historical-data" />
|
||||
<JourneyItem icon="files" title="Document operations" href="/ingestion/document-operations" />
|
||||
<JourneyItem icon="plug" title="Connectors" href="/connectors/overview" />
|
||||
<JourneyItem icon="file-stack" title="Content types" href="/concepts/content-types" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { fetchSession, validateOAuthToken } from "./index"
|
||||
import { fetchSession, validateApiKey, validateOAuthToken } from "./index"
|
||||
|
||||
const API_URL = "https://api.example.com"
|
||||
const ISSUER = `${API_URL}/api/auth`
|
||||
|
|
@ -120,4 +120,64 @@ describe("MCP authentication", () => {
|
|||
status: 403,
|
||||
})
|
||||
})
|
||||
|
||||
function sessionResponse() {
|
||||
return Response.json({
|
||||
user: { id: "user_test", email: "test@example.com" },
|
||||
org: { id: "org_test" },
|
||||
role: "owner",
|
||||
accessType: "full",
|
||||
scope: { type: "full", permission: "write" },
|
||||
})
|
||||
}
|
||||
|
||||
it("validates an sm_ API key via the session endpoint", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_valid_key_0123456789abcdef"
|
||||
|
||||
await expect(validateApiKey(key, API_URL)).resolves.toEqual({
|
||||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
bearerToken: key,
|
||||
scopes: [],
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${API_URL}/v3/session`,
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("caches a validated API key within the TTL", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_cached_key_0123456789abcdef"
|
||||
|
||||
await validateApiKey(key, API_URL)
|
||||
await validateApiKey(key, API_URL)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("rejects an API key the session endpoint refuses", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
||||
)
|
||||
|
||||
await expect(
|
||||
validateApiKey("sm_revoked_key_0123456789abcdef", API_URL),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("rejects malformed API keys without an API request", async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
|
||||
await expect(validateApiKey("sm_short", API_URL)).resolves.toBeNull()
|
||||
await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,6 +52,51 @@ export async function fetchSession(
|
|||
return result.data
|
||||
}
|
||||
|
||||
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
|
||||
// instead of JWT verification. Successful lookups are cached per isolate so a
|
||||
// busy MCP session doesn't re-validate on every JSON-RPC message.
|
||||
const API_KEY_PATTERN = /^sm_\S{17,}$/
|
||||
const API_KEY_CACHE_TTL_MS = 60_000
|
||||
const API_KEY_CACHE_MAX_ENTRIES = 1000
|
||||
|
||||
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
|
||||
|
||||
export function isApiKey(token: string): boolean {
|
||||
return API_KEY_PATTERN.test(token)
|
||||
}
|
||||
|
||||
export async function validateApiKey(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
): Promise<AuthUser | null> {
|
||||
if (!isApiKey(token)) return null
|
||||
|
||||
const cached = apiKeyCache.get(token)
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.user
|
||||
|
||||
try {
|
||||
const session = await fetchSession(token, apiUrl)
|
||||
const organizationId = session.org?.id
|
||||
if (!organizationId) return null
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
bearerToken: token,
|
||||
scopes: [],
|
||||
}
|
||||
if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear()
|
||||
apiKeyCache.set(token, {
|
||||
user,
|
||||
expiresAt: Date.now() + API_KEY_CACHE_TTL_MS,
|
||||
})
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error("API key validation error:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateOAuthToken(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { z } from "zod"
|
|||
import {
|
||||
containerTagSchema,
|
||||
documentsApiResponseSchema,
|
||||
paginationSchema,
|
||||
memoriesListSchema,
|
||||
type ContainerTag,
|
||||
type DocumentMemoryEntry,
|
||||
type DocumentsApiResponse,
|
||||
type DocumentWithMemories,
|
||||
type MemoriesList,
|
||||
type MemoryEntry,
|
||||
type MemoryEntryHistory,
|
||||
} from "../../shared/types"
|
||||
|
||||
const MAX_CHARS = 200000
|
||||
|
|
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
|
|||
pagination: SdkDocumentListResponse["pagination"]
|
||||
}
|
||||
|
||||
const memoryEntryHistorySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
const memoryEntrySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
const memoryEntriesResponseSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
|
||||
// Memory-entry shapes live in shared/types so the client parser and the
|
||||
// listMemories output schema share one definition and can't drift.
|
||||
export type { MemoryEntry, MemoryEntryHistory }
|
||||
export type MemoryEntriesResponse = MemoriesList
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
|
|
@ -149,6 +119,19 @@ function objectProperty(value: unknown, key: string): unknown {
|
|||
: undefined
|
||||
}
|
||||
|
||||
// API error bodies are JSON like {"error": "..."} — unwrap them so users see
|
||||
// the real reason instead of raw JSON or a generic fallback.
|
||||
function extractApiErrorMessage(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string" || !raw) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown }
|
||||
if (typeof parsed.error === "string" && parsed.error) return parsed.error
|
||||
if (typeof parsed.message === "string" && parsed.message)
|
||||
return parsed.message
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
|
||||
export class SupermemoryClient {
|
||||
private client: Supermemory
|
||||
private containerTag: string
|
||||
|
|
@ -371,7 +354,8 @@ export class SupermemoryClient {
|
|||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
const message = extractApiErrorMessage(await response.text())
|
||||
throw Object.assign(new Error(message ?? ""), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
|
@ -432,14 +416,13 @@ export class SupermemoryClient {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw Object.assign(
|
||||
new Error(message || "Failed to fetch memory entries"),
|
||||
{ status: response.status },
|
||||
)
|
||||
const message = extractApiErrorMessage(await response.text())
|
||||
throw Object.assign(new Error(message ?? ""), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return memoryEntriesResponseSchema.parse(await response.json())
|
||||
return memoriesListSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
@ -466,8 +449,7 @@ export class SupermemoryClient {
|
|||
|
||||
const status = objectProperty(error, "status")
|
||||
if (typeof status === "number") {
|
||||
const rawMessage = objectProperty(error, "message")
|
||||
const message = typeof rawMessage === "string" ? rawMessage : undefined
|
||||
const message = extractApiErrorMessage(objectProperty(error, "message"))
|
||||
switch (status) {
|
||||
case 400:
|
||||
case 422:
|
||||
|
|
@ -479,7 +461,7 @@ export class SupermemoryClient {
|
|||
case 403:
|
||||
throw new Error(
|
||||
message ||
|
||||
"Access forbidden. Your account may be restricted or blocked.",
|
||||
"Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status.",
|
||||
)
|
||||
case 404:
|
||||
throw new Error("Not found.")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
|
|||
import { createMcpHandler } from "agents/mcp/server"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
||||
import {
|
||||
isApiKey,
|
||||
validateApiKey,
|
||||
validateOAuthToken,
|
||||
type AuthUser,
|
||||
} from "./auth"
|
||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||
import { createSupermemoryServer } from "./server"
|
||||
import type { ActorContext, ServerEnv } from "./types"
|
||||
|
|
@ -176,7 +181,9 @@ async function handleMcpRequest(
|
|||
|
||||
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
||||
|
||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
const authUser = isApiKey(token)
|
||||
? await validateApiKey(token, apiUrl)
|
||||
: await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||
|
||||
const actor: ActorContext = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod"
|
||||
import {
|
||||
containerTagAccessSchema,
|
||||
memoriesListSchema,
|
||||
paginationSchema,
|
||||
sessionScopeSchema,
|
||||
} from "../../shared/types"
|
||||
|
|
@ -42,33 +43,6 @@ const documentSummarySchema = z.object({
|
|||
summary: z.string().nullable(),
|
||||
})
|
||||
|
||||
const memoryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const memoryEntryOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export const addMemoryOutputSchema = z.object({
|
||||
action: z.enum(["save", "forget"]),
|
||||
success: z.boolean(),
|
||||
|
|
@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({
|
|||
|
||||
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
||||
|
||||
export const listMemoriesOutputSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntryOutputSchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
// Reuse the shared schema so the tool's output contract stays identical to what
|
||||
// the client parses — the two can't drift.
|
||||
export const listMemoriesOutputSchema = memoriesListSchema
|
||||
|
||||
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
|
|||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
org: z.looseObject({ id: z.string().min(1) }).optional(),
|
||||
role: z.string().optional(),
|
||||
accessType: z.enum(["full", "restricted"]).optional(),
|
||||
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||
|
|
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
|
|||
|
||||
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
|
||||
|
||||
// Extracted memory entries from /v4/memories/list. Single source of truth for
|
||||
// both the client parser and the listMemories tool output schema, so the two
|
||||
// can't drift (a mismatch previously produced Ajv "must NOT have additional
|
||||
// properties"). z.object strips unknown API fields on parse, keeping parsed data
|
||||
// matched to the strict MCP output contract while tolerating new API fields.
|
||||
export const memoryEntryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
export const memoryEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
export const memoriesListSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoriesList = z.infer<typeof memoriesListSchema>
|
||||
|
||||
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
||||
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "next dev --port ${PORT:-3004}",
|
||||
"build": "next build",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
EXA_API_KEY=
|
||||
XAI_API_KEY=
|
||||
XAI_API_KEY=
|
||||
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=
|
||||
|
|
|
|||
|
|
@ -6,12 +6,22 @@ import {
|
|||
|
||||
export default async function ConfigureSectionPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ section: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { section } = await params
|
||||
// Default section is canonical at /configure.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
|
||||
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(await searchParams)) {
|
||||
if (typeof value === "string") query.set(key, value)
|
||||
else if (Array.isArray(value)) for (const v of value) query.append(key, v)
|
||||
}
|
||||
const search = query.toString()
|
||||
redirect(search ? `/configure?${search}` : "/configure")
|
||||
}
|
||||
if (!isConfigureSection(section)) notFound()
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
||||
import { PromoCodeHost } from "@/hooks/use-promo-code"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<PromoCodeHost />
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<PWAInstallPrompt />
|
||||
</SettingsModalProvider>
|
||||
|
|
|
|||
|
|
@ -591,6 +591,79 @@ export default function LoginPage() {
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "agentid"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>AgentID</title>
|
||||
<rect
|
||||
height="11"
|
||||
rx="2.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
width="14"
|
||||
x="5"
|
||||
y="8.21"
|
||||
/>
|
||||
<path
|
||||
d="M12 8.21V4.71M12 4.71a1.5 1.5 0 1 0-.01-3 1.5 1.5 0 0 0 .01 3Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<circle
|
||||
cx="9.25"
|
||||
cy="13.21"
|
||||
fill="currentColor"
|
||||
r="1.25"
|
||||
/>
|
||||
<circle
|
||||
cx="14.75"
|
||||
cy="13.21"
|
||||
fill="currentColor"
|
||||
r="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M9 16.21h6"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="AgentID"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "agentid",
|
||||
})
|
||||
setPendingLoginMethod("agentid")
|
||||
signIn
|
||||
.oauth2({
|
||||
callbackURL: getCallbackURL(),
|
||||
providerId: "agentid",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
|
|
|
|||
58
apps/web/app/api/mcp-icon/route.ts
Normal file
58
apps/web/app/api/mcp-icon/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import iconDomains from "@/lib/mcp-icon-domains.json"
|
||||
|
||||
const DOMAIN_RE =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
|
||||
const MAX_ICON_BYTES = 256 * 1024
|
||||
|
||||
const ALLOWED_DOMAINS = new Set(iconDomains.domains)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const domain = request.nextUrl.searchParams
|
||||
.get("domain")
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`,
|
||||
{ next: { revalidate: 60 * 60 * 24 * 7 } },
|
||||
)
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
if (!response.ok || !contentType.startsWith("image/")) {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length") ?? 0)
|
||||
if (contentLength > MAX_ICON_BYTES) {
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
if (!response.body) return new NextResponse(null, { status: 404 })
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let bytes = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > MAX_ICON_BYTES) {
|
||||
await reader.cancel()
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
const body = new Uint8Array(bytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000",
|
||||
"content-type": contentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context"
|
|||
import { useSession } from "@lib/auth"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { ArrowRight, Loader, XCircle } from "lucide-react"
|
||||
import { ArrowRight, XCircle } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
||||
|
||||
|
|
@ -88,7 +87,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Auto-capture of project decisions",
|
||||
"Context-aware suggestions",
|
||||
],
|
||||
icon: "/images/plugins/cursor.svg",
|
||||
icon: "/images/plugins/cursor.png",
|
||||
},
|
||||
codex: {
|
||||
name: "OpenAI Codex",
|
||||
|
|
@ -103,11 +102,77 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
},
|
||||
}
|
||||
|
||||
const MULTI_PLUGIN_FEATURES = [
|
||||
"Share one persistent memory layer across selected coding agents.",
|
||||
"Recall project context, coding decisions, and prior sessions.",
|
||||
"Connect every selected plugin with one approval.",
|
||||
]
|
||||
|
||||
function isKnownPlugin(value: string): boolean {
|
||||
return Object.hasOwn(PLUGIN_INFO, value)
|
||||
}
|
||||
|
||||
function getPluginName(client: string): string {
|
||||
return PLUGIN_INFO[client]?.name ?? "External Tool"
|
||||
}
|
||||
|
||||
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
|
||||
function formatPluginNames(clients: string[]): string {
|
||||
const names = clients.map((id) => getPluginName(id))
|
||||
if (names.length === 0) return "External Tool"
|
||||
if (names.length === 1) return names[0] ?? "External Tool"
|
||||
if (names.length === 2) {
|
||||
return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}`
|
||||
}
|
||||
|
||||
return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}`
|
||||
}
|
||||
|
||||
function encodeBase64UrlJson(value: Record<string, string>): string {
|
||||
return btoa(JSON.stringify(value))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "")
|
||||
}
|
||||
|
||||
function PluginLogoStack({ clients }: { clients: string[] }) {
|
||||
if (clients.length === 0) {
|
||||
return (
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{clients.map((id, index) => {
|
||||
const plugin = PLUGIN_INFO[id]
|
||||
return (
|
||||
<div
|
||||
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
|
||||
key={`${id}-${index}`}
|
||||
style={{ zIndex: clients.length - index }}
|
||||
title={plugin?.name ?? id}
|
||||
>
|
||||
{plugin ? (
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-6 object-contain"
|
||||
height={24}
|
||||
src={plugin.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Status = "loading" | "creating" | "success" | "error"
|
||||
|
||||
const pageWrapperClass =
|
||||
"flex items-center justify-center min-h-screen bg-background p-4"
|
||||
|
|
@ -121,16 +186,34 @@ function AuthConnectContent() {
|
|||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
const { org, organizations, isRestoring } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [status, setStatus] = useState<Status>("loading")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
|
||||
const callback = params.get("callback")
|
||||
const client = params.get("client")
|
||||
const validClient = client && client in PLUGIN_INFO ? client : null
|
||||
const displayName = validClient ? getPluginName(validClient) : "External Tool"
|
||||
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
|
||||
const clientsParam = params.get("clients")
|
||||
const hasClientList = params.has("clients")
|
||||
const rawRequestedClients = useMemo(
|
||||
() =>
|
||||
(clientsParam !== null ? clientsParam.split(",") : client ? [client] : [])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
[client, clientsParam],
|
||||
)
|
||||
const requestedClients = useMemo(
|
||||
() => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))),
|
||||
[rawRequestedClients],
|
||||
)
|
||||
const invalidClients = useMemo(
|
||||
() => rawRequestedClients.filter((value) => !isKnownPlugin(value)),
|
||||
[rawRequestedClients],
|
||||
)
|
||||
const validClient = requestedClients[0] ?? null
|
||||
const displayName = formatPluginNames(requestedClients)
|
||||
const pluginInfo =
|
||||
requestedClients.length === 1 && validClient
|
||||
? PLUGIN_INFO[validClient]
|
||||
: null
|
||||
|
||||
// Redirect new users (logged in but no organization) to onboarding.
|
||||
// Store the current connect URL so onboarding can redirect back here.
|
||||
|
|
@ -166,6 +249,16 @@ function AuthConnectContent() {
|
|||
setError("Invalid callback URL.")
|
||||
return
|
||||
}
|
||||
if (invalidClients.length > 0) {
|
||||
setStatus("error")
|
||||
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
|
||||
return
|
||||
}
|
||||
if (requestedClients.length === 0) {
|
||||
setStatus("error")
|
||||
setError("Invalid or missing client.")
|
||||
return
|
||||
}
|
||||
if (!session || !org) {
|
||||
setStatus("error")
|
||||
setError(
|
||||
|
|
@ -177,17 +270,13 @@ function AuthConnectContent() {
|
|||
try {
|
||||
setStatus("creating")
|
||||
const fetchParams = new URLSearchParams({ callback })
|
||||
if (validClient) fetchParams.set("client", validClient)
|
||||
fetchParams.set("client", requestedClients[0] ?? "")
|
||||
|
||||
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
setStatus("upgrade")
|
||||
return
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -198,7 +287,21 @@ function AuthConnectContent() {
|
|||
setStatus("success")
|
||||
|
||||
const redirectUrl = new URL(callback)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
if (hasClientList) {
|
||||
redirectUrl.searchParams.set(
|
||||
"keys",
|
||||
encodeBase64UrlJson(
|
||||
Object.fromEntries(
|
||||
requestedClients.map((requestedClient) => [
|
||||
requestedClient,
|
||||
data.key,
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
}
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
|
|
@ -208,23 +311,23 @@ function AuthConnectContent() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleUpgrade() {
|
||||
try {
|
||||
setIsUpgrading(true)
|
||||
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
|
||||
await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: safeSuccessUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Upgrade failed:", err)
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Show a spinner while session/org data is loading or while we're about
|
||||
// to redirect to onboarding (prevents a brief flash of the connect card).
|
||||
const isAuthLoading = isPending || isRestoring || organizations === null
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "loading") return
|
||||
if (rawRequestedClients.length === 0) {
|
||||
setStatus("error")
|
||||
setError("Invalid or missing client.")
|
||||
return
|
||||
}
|
||||
if (invalidClients.length > 0) {
|
||||
setStatus("error")
|
||||
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
|
||||
}
|
||||
}, [invalidClients, rawRequestedClients.length, status])
|
||||
|
||||
if (isAuthLoading || shouldRedirectToOnboarding) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
|
|
@ -238,19 +341,7 @@ function AuthConnectContent() {
|
|||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
className="size-6"
|
||||
height={24}
|
||||
src={pluginInfo.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
<PluginLogoStack clients={requestedClients} />
|
||||
<div className="text-center">
|
||||
<h2
|
||||
className={dmSans125ClassName(
|
||||
|
|
@ -265,13 +356,15 @@ function AuthConnectContent() {
|
|||
)}
|
||||
>
|
||||
{pluginInfo?.description ??
|
||||
`Allow ${displayName} to access your Supermemory account.`}
|
||||
(requestedClients.length > 1
|
||||
? "Use one Supermemory account across these plugins."
|
||||
: `Use your Supermemory account with ${displayName}.`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginInfo && (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{pluginInfo.features.map((feature) => (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
|
||||
(feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
|
|
@ -282,16 +375,16 @@ function AuthConnectContent() {
|
|||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"text-[#FAFAFA] font-medium text-[14px]",
|
||||
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
|
|
@ -311,104 +404,6 @@ function AuthConnectContent() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "upgrade") {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
className="size-6"
|
||||
height={24}
|
||||
src={pluginInfo.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2
|
||||
className={dmSans125ClassName(
|
||||
"font-semibold text-[18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{pluginInfo?.name ?? displayName}
|
||||
</h2>
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[13px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
{pluginInfo?.description ??
|
||||
`A paid plan is required to use ${displayName} with Supermemory.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginInfo && (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{pluginInfo.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
className={dmSans125ClassName(
|
||||
"text-[13px] text-[#8B8B8B]",
|
||||
)}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
>
|
||||
{isUpgrading || autumn.isLoading ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin mr-2" />
|
||||
Upgrading…
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro \u2014 $19/month"
|
||||
)}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/settings#billing"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
>
|
||||
View all plans
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
|
|
@ -435,7 +430,7 @@ function AuthConnectContent() {
|
|||
<div className="flex flex-col gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => void handleConnect()}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
|
||||
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Suspense } from "react"
|
|||
import { Toaster } from "@ui/components/sonner"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
import { ThemeProvider } from "@/lib/theme-provider"
|
||||
import { PromoCodeCapture } from "@/hooks/use-promo-code"
|
||||
|
||||
const font = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -95,6 +96,7 @@ export default function RootLayout({
|
|||
includeCredentials={true}
|
||||
headers={{ "X-App-Source": "nova" }}
|
||||
>
|
||||
<PromoCodeCapture />
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<PostHogProvider>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
getConnectionSubtitle,
|
||||
} from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type GDriveSyncScope = "scoped" | "full"
|
||||
|
||||
|
|
@ -309,6 +310,7 @@ interface ConnectContentProps {
|
|||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const { connectorAccess } = useConnectorAccess()
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
|
|
@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils"
|
|||
import { SpaceSelector } from "../space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type TabType = "note" | "link" | "file" | "connect"
|
||||
|
||||
|
|
@ -153,6 +154,7 @@ export function AddDocument({
|
|||
})
|
||||
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const {
|
||||
tokensUsed,
|
||||
searchesUsed,
|
||||
|
|
@ -342,8 +344,10 @@ export function AddDocument({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -442,8 +446,10 @@ export function AddDocument({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { Gmail, Granola, Notion } from "@ui/assets/icons"
|
||||
import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export function SlackMark({ className }: { className?: string }) {
|
||||
|
|
@ -99,6 +99,8 @@ export function brainConnectorIcon(
|
|||
className = "size-[18px]",
|
||||
): React.ReactNode {
|
||||
switch (slug) {
|
||||
case "google-drive":
|
||||
return <GoogleDrive className={className} />
|
||||
case "gmail":
|
||||
return <Gmail className={className} />
|
||||
case "github":
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
|||
import { useQueryState } from "nuqs"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
||||
import { TrialSetupBanner } from "@/components/trial-setup-banner"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import {
|
||||
|
|
@ -170,6 +172,7 @@ export function BrainHomeView() {
|
|||
const o = useBrainOverview()
|
||||
const trial = useBrainTrial()
|
||||
const board = useConnectionsBoard()
|
||||
const { needsSetup } = useTrialStatus()
|
||||
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
||||
const milestones = [
|
||||
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
||||
|
|
@ -186,6 +189,7 @@ export function BrainHomeView() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||
<TrialSetupBanner />
|
||||
<StatsRow
|
||||
memories={o.memoriesCount}
|
||||
connected={o.connectedCount}
|
||||
|
|
@ -195,7 +199,7 @@ export function BrainHomeView() {
|
|||
setupTotal={milestonesTotal}
|
||||
lastUpdatedAt={o.lastUpdatedAt}
|
||||
/>
|
||||
{board.slack && !board.slack.connected && <SlackBanner />}
|
||||
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
{board.showBoard && <ConnectToolsCard board={board} />}
|
||||
|
|
@ -486,7 +490,7 @@ function BrainTimeline({
|
|||
canInvite: boolean
|
||||
toolsCardVisible: boolean
|
||||
}) {
|
||||
const trial = useBrainTrial()
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
|
|
@ -532,12 +536,13 @@ function BrainTimeline({
|
|||
title: slackConnected ? "Slack connected" : "Connect Slack",
|
||||
hint: slackConnected
|
||||
? undefined
|
||||
: trial.state === "trialing"
|
||||
? "Ask your brain from any channel."
|
||||
: "Starts your 14-day free trial. No credit card needed.",
|
||||
action: slackConnected
|
||||
? undefined
|
||||
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
||||
: needsSetup
|
||||
? "Starts with your trial."
|
||||
: "Ask your brain from any channel.",
|
||||
action:
|
||||
slackConnected || needsSetup
|
||||
? undefined
|
||||
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
||||
},
|
||||
...(rollout != null
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { cn } from "@lib/utils"
|
|||
import { ArrowRight, Loader2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
|
@ -192,6 +193,7 @@ export const CONNECT_TOOLS_CARD_ID = "connect-tools"
|
|||
export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { setViewMode } = useViewMode()
|
||||
const { loading, featured, overflow, busy, isConnected, connect } = board
|
||||
const { needsSetup } = useTrialStatus()
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -209,11 +211,19 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
|||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already uses.
|
||||
{needsSetup
|
||||
? "Starts with your trial."
|
||||
: "Give your Slack agent live access to the apps your team already uses."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[12px] bg-[#14161A]",
|
||||
needsSetup && "pointer-events-none opacity-40 select-none",
|
||||
)}
|
||||
aria-disabled={needsSetup || undefined}
|
||||
>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
|
|
@ -248,6 +258,7 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
|||
|
||||
export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { previewApps, isConnected, connectedCount } = board
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const prompts = previewApps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
.slice(0, 6)
|
||||
|
|
@ -275,12 +286,19 @@ export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
{needsSetup
|
||||
? "Starts with your trial."
|
||||
: connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[12px] bg-[#14161A]",
|
||||
needsSetup && "opacity-40 select-none",
|
||||
)}
|
||||
>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.slug}
|
||||
|
|
@ -426,6 +444,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
|||
}
|
||||
|
||||
export function SlackBanner() {
|
||||
const { needsSetup } = useTrialStatus()
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
||||
|
|
@ -472,12 +491,20 @@ export function SlackBanner() {
|
|||
</div>
|
||||
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
href={
|
||||
needsSetup ? "/onboarding" : `${BACKEND}/brain/slack/oauth/install`
|
||||
}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
|
||||
>
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
{needsSetup ? (
|
||||
<span>Start trial</span>
|
||||
) : (
|
||||
<>
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export function HomeChatComposer({
|
|||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.5")
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
getDefaultReasoningEffort("grok-4.3"),
|
||||
getDefaultReasoningEffort("grok-4.5"),
|
||||
)
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
|
|
|
|||
|
|
@ -206,11 +206,11 @@ export function ChatSidebar({
|
|||
>([])
|
||||
const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(
|
||||
initialSelectedModel ?? "grok-4.3",
|
||||
initialSelectedModel ?? "grok-4.5",
|
||||
)
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
initialReasoningEffort ??
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"),
|
||||
)
|
||||
const selectedModelRef = useRef(selectedModel)
|
||||
selectedModelRef.current = selectedModel
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ export default function ChatModelSelector({
|
|||
minimal = false,
|
||||
dropdownDirection = "up",
|
||||
}: ChatModelSelectorProps = {}) {
|
||||
const [internalModel, setInternalModel] =
|
||||
useState<ModelId>("claude-sonnet-4.6")
|
||||
const [internalModel, setInternalModel] = useState<ModelId>("claude-sonnet-5")
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
LifeBuoy,
|
||||
LayoutGrid,
|
||||
MenuIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Settings2,
|
||||
|
|
@ -55,6 +56,7 @@ const BACKEND =
|
|||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
|
||||
interface CompanyBrainHeaderProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +110,10 @@ function useSlackStatus() {
|
|||
})
|
||||
}
|
||||
|
||||
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
||||
export function CompanyBrainHeader({
|
||||
onAddMemory,
|
||||
onOpenSearch,
|
||||
}: CompanyBrainHeaderProps) {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
|
|
@ -412,6 +417,18 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
{onAddMemory && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={onAddMemory}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Plus className="size-4 text-[#737373]" />
|
||||
Add memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={goOverview}
|
||||
className={menuItemClass}
|
||||
|
|
@ -498,6 +515,29 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
) : (
|
||||
<>
|
||||
<BrainTrialPill className="h-9 px-3" />
|
||||
{onAddMemory && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onAddMemory}
|
||||
aria-label="Add memory"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Add</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Add memory (C)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canInvite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -45,42 +45,44 @@ export function CompanyBrainPromo() {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
|
||||
"relative flex items-start gap-3 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md sm:items-center sm:gap-4",
|
||||
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
|
||||
<div className="mt-1 flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef] sm:mt-0">
|
||||
<Logo className="h-4 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold text-[#fafafa]">
|
||||
Give your team a Company Brain
|
||||
</p>
|
||||
<p className="text-[13px] text-[#a1a1a1]">
|
||||
Lives in your Slack. Answers from your team's tools, and brings things
|
||||
up before you ask.
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:contents">
|
||||
<div className="min-w-0 pt-1 pr-7 sm:flex-1 sm:pt-0 sm:pr-0">
|
||||
<p className="text-[15px] font-semibold text-[#fafafa]">
|
||||
Give your team a Company Brain
|
||||
</p>
|
||||
<p className="text-[11px] leading-snug text-[#a1a1a1] sm:text-[13px] sm:leading-normal">
|
||||
Lives in your Slack. Answers from your team's tools, and brings
|
||||
things up before you ask.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9! min-h-9 w-fit shrink-0 self-end gap-1.5 rounded-full! px-3 font-medium sm:self-auto",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
variant="headers"
|
||||
>
|
||||
Set it up
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
variant="headers"
|
||||
>
|
||||
Set it up
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
className="absolute right-2.5 top-2.5 shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa] sm:static"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -162,16 +162,19 @@ export function ConfigureView() {
|
|||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-5">
|
||||
<h2
|
||||
id="configure-section-title"
|
||||
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
|
||||
>
|
||||
{active.label}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
|
||||
{active.description}
|
||||
</p>
|
||||
<header className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2
|
||||
id="configure-section-title"
|
||||
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
|
||||
>
|
||||
{active.label}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
|
||||
{active.description}
|
||||
</p>
|
||||
</div>
|
||||
<div id="configure-section-actions" className="shrink-0" />
|
||||
</header>
|
||||
|
||||
<ErrorBoundary
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import { SlackConnectCard } from "@/components/slack-connect-card"
|
||||
import { TrialSetupBanner } from "@/components/trial-setup-banner"
|
||||
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||
|
|
@ -1344,6 +1345,7 @@ export function DashboardView({
|
|||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
||||
<TrialSetupBanner />
|
||||
<SlackConnectCard />
|
||||
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||
|
||||
|
|
|
|||
82
apps/web/components/directory/connector-card.tsx
Normal file
82
apps/web/components/directory/connector-card.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ReactNode } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
// Shared connector/integration card shell: icon, name, subtitle, optional
|
||||
// top-right slot, and a footer split into a status side and an action side.
|
||||
export function ConnectorCard({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
topRight,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
topRight?: ReactNode
|
||||
footerLeft: ReactNode
|
||||
footerRight?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{topRight}
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<div className="flex min-w-0 items-center gap-3">{footerLeft}</div>
|
||||
{footerRight}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScopeChip({
|
||||
label,
|
||||
connected,
|
||||
}: {
|
||||
label: string
|
||||
connected: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-[7px] shrink-0 rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
117
apps/web/components/directory/section-rail.tsx
Normal file
117
apps/web/components/directory/section-rail.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export const sectionLabelClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)
|
||||
|
||||
// Horizontally scrollable card rail with a section heading — shared by the
|
||||
// main integrations directory and the Company Brain connections directory.
|
||||
// Arrows appear only when the content actually overflows.
|
||||
export function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
labelSlot,
|
||||
scrollbar = "hidden",
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
labelSlot?: ReactNode
|
||||
scrollbar?: "hidden" | "visible"
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setHasOverflow(el.scrollWidth > el.clientWidth + 4)
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className={sectionLabelClass}>{label}</h3>
|
||||
{labelSlot}
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
{hasOverflow ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1",
|
||||
scrollbar === "visible" ? "scrollbar-thin sm:pb-2" : "scrollbar-none",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard card width inside a rail: full-width stacked on mobile, 2-up on
|
||||
// small screens, 3-up on large.
|
||||
export const railItemClass =
|
||||
"w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"
|
||||
|
|
@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) =>
|
|||
export function Header(props: HeaderProps) {
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
if (hasCompanyBrain) {
|
||||
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
|
||||
return (
|
||||
<CompanyBrainHeader
|
||||
onAddMemory={props.onAddMemory}
|
||||
onOpenSearch={props.onOpenSearch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PersonalBrainHeader {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ export function HighlightsCard({
|
|||
if (isReplyOpen) replyInputRef.current?.focus()
|
||||
}, [isReplyOpen])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
|
||||
useEffect(() => {
|
||||
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
setIsExpanded(false)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { SectionRail } from "@/components/directory/section-rail"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -71,8 +72,14 @@ import {
|
|||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
import {
|
||||
CopyButton,
|
||||
INSET,
|
||||
InstallSteps,
|
||||
PillButton,
|
||||
} from "./integrations/install-steps"
|
||||
import {
|
||||
ShortcutsConnectButtons,
|
||||
useShortcutsConnect,
|
||||
|
|
@ -80,6 +87,7 @@ import {
|
|||
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
|
||||
import { GranolaConnectModal } from "./granola-connect-modal"
|
||||
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
|
|
@ -536,13 +544,13 @@ const SECTIONS: Array<{
|
|||
action: { type: "external", href: POKE_RECIPE_URL },
|
||||
},
|
||||
{
|
||||
kind: "client",
|
||||
id: "shortcuts",
|
||||
name: "Apple Shortcuts",
|
||||
tagline: "Add memories from iPhone, iPad or Mac",
|
||||
simpleTitle: "Save anything from your phone or Mac",
|
||||
icon: <AppleShortcutsIcon />,
|
||||
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
||||
kind: "import",
|
||||
id: "x-bookmarks",
|
||||
name: "Import X bookmarks",
|
||||
tagline: "Turn your X/Twitter bookmarks into memories",
|
||||
simpleTitle: "Turn your X bookmarks into memory",
|
||||
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
||||
viewMode: "import" as ViewParamValue,
|
||||
},
|
||||
{
|
||||
kind: "client",
|
||||
|
|
@ -555,13 +563,13 @@ const SECTIONS: Array<{
|
|||
dev: true,
|
||||
},
|
||||
{
|
||||
kind: "import",
|
||||
id: "x-bookmarks",
|
||||
name: "Import X bookmarks",
|
||||
tagline: "Turn your X/Twitter bookmarks into memories",
|
||||
simpleTitle: "Turn your X bookmarks into memory",
|
||||
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
||||
viewMode: "import" as ViewParamValue,
|
||||
kind: "client",
|
||||
id: "shortcuts",
|
||||
name: "Apple Shortcuts",
|
||||
tagline: "Add memories from iPhone, iPad or Mac",
|
||||
simpleTitle: "Save anything from your phone or Mac",
|
||||
icon: <AppleShortcutsIcon />,
|
||||
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -637,6 +645,206 @@ function IconBox({
|
|||
)
|
||||
}
|
||||
|
||||
const PLUGIN_COMMANDS: InstallStep[] = [
|
||||
{
|
||||
code: "npx supermemory plugin",
|
||||
copyLabel: "Install plugins",
|
||||
title: "Install plugins",
|
||||
description:
|
||||
"Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin login",
|
||||
copyLabel: "Reconnect plugins",
|
||||
title: "Reconnect plugins",
|
||||
description:
|
||||
"Run browser OAuth again for plugins that are already installed, without reinstalling them.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin uninstall",
|
||||
copyLabel: "Uninstall plugins",
|
||||
title: "Uninstall plugins",
|
||||
description:
|
||||
"Remove selected plugin integrations while keeping your credentials and memories.",
|
||||
},
|
||||
]
|
||||
|
||||
const PLUGIN_COMMAND_CLIENTS = [
|
||||
"claude_code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"opencode",
|
||||
] as const
|
||||
|
||||
type PluginSetupTab = "agent" | "manual"
|
||||
|
||||
const PLUGIN_CLI_TARGETS: Partial<Record<string, string>> = {
|
||||
claude_code: "claude",
|
||||
codex: "codex",
|
||||
cursor: "cursor",
|
||||
opencode: "opencode",
|
||||
}
|
||||
|
||||
function pluginAgentPrompt(plugin: PluginInfo): string {
|
||||
const cliTarget = PLUGIN_CLI_TARGETS[plugin.id]
|
||||
if (cliTarget) {
|
||||
return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.`
|
||||
}
|
||||
|
||||
const docsInstruction = plugin.docsUrl
|
||||
? ` Follow the official setup instructions at ${plugin.docsUrl}.`
|
||||
: " Follow its official setup instructions."
|
||||
return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.`
|
||||
}
|
||||
|
||||
function PluginSetupMethodTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PluginSetupTab
|
||||
onChange: (value: PluginSetupTab) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label="Setup method"
|
||||
>
|
||||
{(["agent", "manual"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
|
||||
value === tab
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
onClick={() => onChange(tab)}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={value === tab}
|
||||
>
|
||||
{tab === "agent" ? "Agent instructions" : "Manual instructions"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) {
|
||||
const prompt = pluginAgentPrompt(plugin)
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] text-[#E4E4E7]">
|
||||
{prompt}
|
||||
</p>
|
||||
<CopyButton text={prompt} label="Agent instructions" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginCommandsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
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",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[620px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Supermemory plugin commands
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Claude Code, Cursor, Codex, and OpenCode"
|
||||
className="flex shrink-0 -space-x-2"
|
||||
>
|
||||
{PLUGIN_COMMAND_CLIENTS.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
return (
|
||||
<span
|
||||
key={pluginId}
|
||||
className="flex size-8 items-center justify-center rounded-[9px] border border-white/[0.12] bg-[#0D121A] p-1.5 shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src={plugin.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Plugin commands
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">
|
||||
Install, reconnect, or remove integrations from one CLI.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-3 sm:p-4",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<InstallSteps steps={PLUGIN_COMMANDS} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 pt-1">
|
||||
<p className="text-[11px] text-[#737373]">
|
||||
Run these commands from your terminal.
|
||||
</p>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type InfoUseCase = {
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -2067,6 +2275,7 @@ function ItemCard({
|
|||
docsUrl,
|
||||
leftIndicator,
|
||||
statusSlot,
|
||||
layoutClassName,
|
||||
}: {
|
||||
actionSlot: ReactNode
|
||||
infoActionSlot?: ReactNode
|
||||
|
|
@ -2081,6 +2290,7 @@ function ItemCard({
|
|||
docsUrl?: string
|
||||
leftIndicator?: ReactNode
|
||||
statusSlot?: ReactNode
|
||||
layoutClassName?: string
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
return (
|
||||
|
|
@ -2098,6 +2308,9 @@ function ItemCard({
|
|||
className={cn(
|
||||
"group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center",
|
||||
layoutClassName,
|
||||
)}
|
||||
>
|
||||
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
|
||||
|
|
@ -2115,7 +2328,12 @@ function ItemCard({
|
|||
<div className="flex shrink-0 items-start justify-between gap-2">
|
||||
<IconBox>{icon}</IconBox>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3",
|
||||
id === "shortcuts" && "max-sm:contents",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{leftIndicator}
|
||||
|
|
@ -2139,7 +2357,13 @@ function ItemCard({
|
|||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:col-span-2 max-sm:row-start-2 max-sm:w-full",
|
||||
)}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
|
||||
<div
|
||||
className="hidden min-w-0 flex-1 sm:flex"
|
||||
|
|
@ -2150,7 +2374,11 @@ function ItemCard({
|
|||
</div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
|
||||
<div
|
||||
className="flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]"
|
||||
className={cn(
|
||||
"flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -2453,95 +2681,6 @@ function CategoryFilterToggle({
|
|||
)
|
||||
}
|
||||
|
||||
function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationsView({
|
||||
publicMode = false,
|
||||
onOpenDocument,
|
||||
|
|
@ -2555,6 +2694,7 @@ export function IntegrationsView({
|
|||
const { allProjects } = useContainerTags()
|
||||
const shortcutsConnect = useShortcutsConnect()
|
||||
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
||||
const promoCode = usePromoCode()
|
||||
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
||||
// stay on hasProProduct. See useConnectorAccess.
|
||||
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
||||
|
|
@ -2566,18 +2706,21 @@ export function IntegrationsView({
|
|||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
||||
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
pluginId: string | null
|
||||
loading: boolean
|
||||
}>({ open: false, key: "", pluginId: null, loading: false })
|
||||
const [pluginSetupTab, setPluginSetupTab] = useState<PluginSetupTab>("agent")
|
||||
const openPluginSetup = useCallback((pluginId: string) => {
|
||||
setPluginSetupTab("agent")
|
||||
setNewKey({ open: true, key: "", pluginId, loading: false })
|
||||
}, [])
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryFn: async () => {
|
||||
|
|
@ -2747,11 +2890,6 @@ export function IntegrationsView({
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -2761,12 +2899,7 @@ export function IntegrationsView({
|
|||
},
|
||||
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
||||
onError: (err) => {
|
||||
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
|
||||
setNewKey((s) =>
|
||||
s.loading
|
||||
? { open: false, key: "", pluginId: null, loading: false }
|
||||
: s,
|
||||
)
|
||||
setNewKey((s) => ({ ...s, loading: false }))
|
||||
toast.error("Failed to connect plugin", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
})
|
||||
|
|
@ -2776,10 +2909,32 @@ export function IntegrationsView({
|
|||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||
},
|
||||
onSuccess: (data, pluginId) => {
|
||||
setNewKey({ open: true, key: data.key, pluginId, loading: false })
|
||||
setNewKey((s) =>
|
||||
s.open && s.pluginId === pluginId
|
||||
? { ...s, key: data.key, loading: false }
|
||||
: s,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const generatePluginKey = () => {
|
||||
const pluginId = newKey.pluginId
|
||||
if (
|
||||
!pluginId ||
|
||||
newKey.key ||
|
||||
newKey.loading ||
|
||||
createPluginKeyMutation.isPending
|
||||
)
|
||||
return
|
||||
setNewKey((s) => ({ ...s, loading: true }))
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
}
|
||||
|
||||
const selectPluginSetupTab = (tab: PluginSetupTab) => {
|
||||
setPluginSetupTab(tab)
|
||||
if (tab === "manual") generatePluginKey()
|
||||
}
|
||||
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
|
|
@ -2830,8 +2985,10 @@ export function IntegrationsView({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: checkoutPlanId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -2842,7 +2999,7 @@ export function IntegrationsView({
|
|||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
},
|
||||
[autumn],
|
||||
[autumn, promoCode],
|
||||
)
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
|
|
@ -2909,10 +3066,7 @@ export function IntegrationsView({
|
|||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
// Open instantly; the key fills in on mint. The ?connect param stays the source
|
||||
// of truth until the modal closes.
|
||||
setNewKey({ open: true, key: "", pluginId: target, loading: true })
|
||||
createPluginKeyMutation.mutate(target)
|
||||
openPluginSetup(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -2947,8 +3101,8 @@ export function IntegrationsView({
|
|||
redirectToLogin,
|
||||
setConnectTarget,
|
||||
setAddDoc,
|
||||
createPluginKeyMutation,
|
||||
handleUpgrade,
|
||||
openPluginSetup,
|
||||
])
|
||||
|
||||
const closeMcpModal = () => {
|
||||
|
|
@ -3263,7 +3417,7 @@ export function IntegrationsView({
|
|||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
createPluginKeyMutation.mutate("claude_code")
|
||||
openPluginSetup("claude_code")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -3341,7 +3495,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
className={cn(
|
||||
|
|
@ -3362,12 +3516,7 @@ export function IntegrationsView({
|
|||
<FinishSetupButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
|
||||
if (connectingPlugin) return
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
return
|
||||
}
|
||||
setFinishSetupPluginId(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
@ -3384,7 +3533,7 @@ export function IntegrationsView({
|
|||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3554,7 +3703,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3629,7 +3778,7 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const renderItemCard = (item: Item) => (
|
||||
const renderItemCard = (item: Item, layoutClassName?: string) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
actionSlot={renderRight(item)}
|
||||
|
|
@ -3645,6 +3794,7 @@ export function IntegrationsView({
|
|||
docsUrl={item.docsUrl}
|
||||
leftIndicator={renderLeftIndicator(item)}
|
||||
statusSlot={renderStatus(item)}
|
||||
layoutClassName={layoutClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -3666,10 +3816,6 @@ export function IntegrationsView({
|
|||
!isAutumnLoading &&
|
||||
!hasProProduct &&
|
||||
!isFreeTierPlugin(connectedPluginId)
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
|
|
@ -3754,7 +3900,14 @@ export function IntegrationsView({
|
|||
</p>
|
||||
) : q || category !== "all" ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleItems.map((item) => renderItemCard(item))}
|
||||
{visibleItems.map((item) =>
|
||||
renderItemCard(
|
||||
item,
|
||||
item.id === "shortcuts"
|
||||
? "sm:w-max sm:min-w-full"
|
||||
: undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -3767,6 +3920,23 @@ export function IntegrationsView({
|
|||
<SectionRail
|
||||
key={cat}
|
||||
label={CATEGORY_LABEL[cat]}
|
||||
labelSlot={
|
||||
cat === "plugins" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={pluginCommandsOpen}
|
||||
onClick={() => setPluginCommandsOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center gap-1.5 rounded-full text-[10px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4BA0FA]/60 sm:text-[11px]",
|
||||
)}
|
||||
>
|
||||
<span>Install plugins with one command</span>
|
||||
<NewChip />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
headerSlot={
|
||||
cat === "ai-clients" && activeMcpKey ? (
|
||||
<McpConnectedPill
|
||||
|
|
@ -3812,6 +3982,11 @@ export function IntegrationsView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<PluginCommandsDialog
|
||||
open={pluginCommandsOpen}
|
||||
onOpenChange={setPluginCommandsOpen}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={newKey.open}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -3821,7 +3996,10 @@ export function IntegrationsView({
|
|||
pluginId: open ? s.pluginId : null,
|
||||
loading: open ? s.loading : false,
|
||||
}))
|
||||
if (!open) void setConnectTarget(null)
|
||||
if (!open) {
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
|
|
@ -3854,9 +4032,11 @@ export function IntegrationsView({
|
|||
Set up {dialogPlugin?.name ?? "your plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
{newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Copy your key and run these steps to finish."}
|
||||
{pluginSetupTab === "agent"
|
||||
? "Copy this prompt into your coding agent."
|
||||
: newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Follow these steps to finish manually."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -3889,17 +4069,30 @@ export function IntegrationsView({
|
|||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
"min-w-0 space-y-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{newKey.loading ? (
|
||||
<PluginSetupMethodTabs
|
||||
value={pluginSetupTab}
|
||||
onChange={selectPluginSetupTab}
|
||||
/>
|
||||
{pluginSetupTab === "agent" && dialogPlugin ? (
|
||||
<PluginAgentInstructions plugin={dialogPlugin} />
|
||||
) : newKey.loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Generating your key…
|
||||
</div>
|
||||
) : (
|
||||
) : newKey.key ? (
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
We couldn't generate the key for the manual setup.
|
||||
</p>
|
||||
<PillButton onClick={generatePluginKey}>Try again</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3913,6 +4106,7 @@ export function IntegrationsView({
|
|||
pluginId: null,
|
||||
loading: false,
|
||||
})
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}}
|
||||
className={cn(
|
||||
|
|
@ -4051,7 +4245,7 @@ export function IntegrationsView({
|
|||
if (!connectedPluginId) return
|
||||
const pluginId = connectedPluginId
|
||||
setConnectedPluginId(null)
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
openPluginSetup(pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -4082,91 +4276,6 @@ export function IntegrationsView({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
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",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
Complete install in the tool — this card turns active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupSteps.length > 0 ? (
|
||||
<InstallSteps steps={finishSetupSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication, then send a test memory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
interface ConnectedPlugin {
|
||||
id: string
|
||||
|
|
@ -415,48 +416,11 @@ function PluginRow({
|
|||
)
|
||||
}
|
||||
|
||||
type TierFilter = "all" | "pro" | "free"
|
||||
|
||||
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "free", label: "Free" },
|
||||
]
|
||||
|
||||
function TierFilterToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TierFilter
|
||||
onChange: (value: TierFilter) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
|
||||
{TIER_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => onChange(filter.value)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-full px-3 h-7 text-[12px] font-medium transition-colors",
|
||||
value === filter.value
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsDetail() {
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
|
|
@ -572,11 +536,6 @@ export function PluginsDetail() {
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -613,8 +572,10 @@ export function PluginsDetail() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -635,17 +596,12 @@ export function PluginsDetail() {
|
|||
)
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
const filtered = catalogRows.filter((id) => {
|
||||
if (tierFilter === "free") return isFreeTierPlugin(id)
|
||||
if (tierFilter === "pro") return !isFreeTierPlugin(id)
|
||||
return true
|
||||
})
|
||||
// Connected plugins float to the top (stable within each group).
|
||||
return [...filtered].sort(
|
||||
return [...catalogRows].sort(
|
||||
(a, b) =>
|
||||
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
|
||||
)
|
||||
}, [catalogRows, tierFilter, connectedPluginIds])
|
||||
}, [catalogRows, connectedPluginIds])
|
||||
|
||||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
|
|
@ -684,12 +640,7 @@ export function PluginsDetail() {
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionHeader>Plugins</SectionHeader>
|
||||
{catalogRows.length > 0 && (
|
||||
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
|
||||
)}
|
||||
</div>
|
||||
<SectionHeader>Plugins</SectionHeader>
|
||||
<div className="flex flex-col">
|
||||
{visibleRows.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
|
|||
}) {
|
||||
const { connect, isPending, pendingType } = controller
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
ArrowRight,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type ResearchEvent,
|
||||
|
|
@ -30,6 +32,9 @@ import {
|
|||
UserAvatar,
|
||||
} from "./step-about"
|
||||
import { ResearchActionRail } from "./research-action-rail"
|
||||
import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
type CompanyBrainConfirmResult,
|
||||
type CompanyBrainOrganizationChoice,
|
||||
|
|
@ -52,7 +57,7 @@ interface CompanyBrainOnboardingProps {
|
|||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Phase = "confirm" | "research"
|
||||
type Phase = "confirm" | "trial" | "research"
|
||||
|
||||
function normalizeDomain(input: string): string {
|
||||
const host = input
|
||||
|
|
@ -82,13 +87,36 @@ export function CompanyBrainOnboarding({
|
|||
onUsePersonal,
|
||||
}: CompanyBrainOnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm")
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const resumedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (resumedRef.current) return
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
|
||||
resumedRef.current = true
|
||||
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
|
||||
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
|
||||
setPhase("research")
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (resumedRef.current || !needsSetup || phase !== "confirm") return
|
||||
resumedRef.current = true
|
||||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}, [needsSetup, phase])
|
||||
const { org } = useAuth()
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [organizationChoices, setOrganizationChoices] = useState<
|
||||
CompanyBrainOrganizationChoice[] | null
|
||||
>(null)
|
||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
||||
const clean = normalizeDomain(domain)
|
||||
// Returning from checkout remounts and reseeds local state from the email domain,
|
||||
// so past the confirm step the org's stored domain is the one to trust.
|
||||
const confirmedDomain = getBrainWorkspaceDomain(org?.metadata)
|
||||
const clean = normalizeDomain(
|
||||
phase === "confirm" ? domain : confirmedDomain || domain,
|
||||
)
|
||||
const queryClient = useQueryClient()
|
||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||
const researchDone = researchStatus === "done"
|
||||
|
|
@ -107,7 +135,8 @@ export function CompanyBrainOnboarding({
|
|||
}
|
||||
setOrganizationChoices(null)
|
||||
setServerSchedulesResearch(result.serverSchedulesResearch)
|
||||
setPhase("research")
|
||||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}
|
||||
|
||||
// New-org signup schedules research after provisioning; if that hook is slow
|
||||
|
|
@ -203,9 +232,9 @@ export function CompanyBrainOnboarding({
|
|||
<main
|
||||
className={cn(
|
||||
"relative z-10 flex-1 flex flex-col min-h-0",
|
||||
phase === "confirm"
|
||||
? "justify-center items-center px-4 md:px-10"
|
||||
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
|
||||
phase === "research"
|
||||
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
|
||||
: "justify-center items-center px-4 md:px-10",
|
||||
)}
|
||||
>
|
||||
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
|
||||
|
|
@ -215,13 +244,25 @@ export function CompanyBrainOnboarding({
|
|||
style={cardSurfaceStyle}
|
||||
className={cn(
|
||||
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
|
||||
phase === "confirm"
|
||||
? "max-w-xl p-6 md:p-8"
|
||||
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
|
||||
phase === "research"
|
||||
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
|
||||
: phase === "trial"
|
||||
? "max-w-4xl p-6 md:p-7"
|
||||
: "max-w-xl p-6 md:p-8",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{phase === "confirm" ? (
|
||||
{phase === "trial" ? (
|
||||
<motion.div
|
||||
key="trial"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<StepTrial onActive={() => setPhase("research")} />
|
||||
</motion.div>
|
||||
) : phase === "confirm" ? (
|
||||
<motion.div
|
||||
key="confirm"
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -525,6 +525,8 @@ function SlackStepBody({
|
|||
<div>
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
|
|
@ -534,7 +536,7 @@ function SlackStepBody({
|
|||
Add to Slack
|
||||
</a>
|
||||
<p className="mt-2 text-center text-[11px] font-medium leading-[1.5] text-[#525D6E]">
|
||||
Starts your 14-day free trial. No credit card needed.
|
||||
Included in your 14-day trial.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react"
|
|||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type SourceId =
|
||||
| "drive"
|
||||
|
|
@ -97,7 +98,7 @@ type SourceId =
|
|||
| "raycast"
|
||||
type SourceState = "idle" | "connecting" | "connected" | "waitlist"
|
||||
type DriveScope = "selective" | "full"
|
||||
type RequiredPlan = "pro" | "max"
|
||||
type RequiredPlan = "pro" | "max" | "scale"
|
||||
|
||||
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
|
||||
"google-drive": "drive",
|
||||
|
|
@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
|
|||
const PLAN_LABELS: Record<RequiredPlan, string> = {
|
||||
pro: "Pro",
|
||||
max: "Max",
|
||||
scale: "Scale",
|
||||
}
|
||||
|
||||
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
|
||||
|
|
@ -148,11 +150,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
credits: "$20",
|
||||
productId: "api_pro",
|
||||
description: "For people building with AI memory",
|
||||
features: [
|
||||
"Auto top-up when balance runs low",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Priority support",
|
||||
],
|
||||
features: ["Auto top-up when balance runs low", "Priority support"],
|
||||
},
|
||||
{
|
||||
id: "max",
|
||||
|
|
@ -277,7 +275,12 @@ export function StepSources({
|
|||
const [granolaOpen, setGranolaOpen] = useState(false)
|
||||
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
|
||||
const [requestedConnector, setRequestedConnector] = useState("This connector")
|
||||
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
|
||||
const {
|
||||
hasMax,
|
||||
hasScale,
|
||||
connectorAccess,
|
||||
loading: planLoading,
|
||||
} = useConnectorAccess()
|
||||
const { org, isRestoring } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -362,10 +365,12 @@ export function StepSources({
|
|||
}
|
||||
}, [connectedParam])
|
||||
|
||||
// company_brain unlocks pro connectors; max stays gated
|
||||
// company_brain unlocks pro connectors; max and scale stay gated, and a
|
||||
// higher tier satisfies a lower requirement.
|
||||
const isLocked = (plan?: RequiredPlan) => {
|
||||
if (!plan || planLoading) return false
|
||||
if (plan === "max") return !hasMax
|
||||
if (plan === "scale") return !hasScale
|
||||
if (plan === "max") return !(hasMax || hasScale)
|
||||
return !connectorAccess
|
||||
}
|
||||
|
||||
|
|
@ -610,6 +615,7 @@ function OnboardingPlansModal({
|
|||
requestedPlan: RequiredPlan
|
||||
}) {
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const { currentPlan, isLoading } = useTokenUsage(autumn)
|
||||
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
|
||||
null,
|
||||
|
|
@ -628,8 +634,10 @@ function OnboardingPlansModal({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
promoCode.clear()
|
||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||
return
|
||||
|
|
@ -1185,14 +1193,14 @@ function MoreSourcesGrid({
|
|||
icon={<Github className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.github ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("max")}
|
||||
requiredPlan="max"
|
||||
locked={isLocked("scale")}
|
||||
requiredPlan="scale"
|
||||
perks={[
|
||||
"PRs and issues parsed",
|
||||
"READMEs and docs indexed",
|
||||
"Stays in sync with new activity",
|
||||
]}
|
||||
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
|
||||
onConnect={guard("scale", "GitHub", () => requestWaitlist("github"))}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<GranolaSourceCard
|
||||
|
|
|
|||
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal file
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client"
|
||||
|
||||
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
|
||||
import { GradientLogo } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowRight, Loader2, ShieldCheck } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
export const CHECKOUT_RETURN_PARAM = "brainTrial"
|
||||
|
||||
const TRIAL_DAYS = 14
|
||||
/** The only reminder that lands before the charge; 15 and 17 are post-trial. */
|
||||
const REMINDER_DAY = 12
|
||||
const MONTHLY_PRICE = "$100"
|
||||
|
||||
function checkoutReturnUrl(): string {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function dayOffset(days: number): string {
|
||||
const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
||||
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
const ORBIT = [
|
||||
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
|
||||
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
|
||||
{ key: "notion", r: 74, deg: 236, node: <Notion className="size-4" /> },
|
||||
{ key: "drive", r: 112, deg: 58, node: <GoogleDrive className="size-4" /> },
|
||||
{ key: "granola", r: 112, deg: 172, node: <Granola className="size-4" /> },
|
||||
{ key: "mcp", r: 112, deg: 296, node: <MCPIcon className="size-4" /> },
|
||||
]
|
||||
|
||||
const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]"
|
||||
const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]"
|
||||
|
||||
function BrainPanel() {
|
||||
return (
|
||||
<div className="relative hidden aspect-[3/2] w-[56%] shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#0B0E13] ring-1 ring-white/[0.06] md:flex">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute size-44 rounded-full bg-[#4BA0FA]/15 blur-3xl"
|
||||
/>
|
||||
|
||||
<div aria-hidden="true" className="relative size-[248px]">
|
||||
<span className="absolute left-1/2 top-1/2 size-[148px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.07]" />
|
||||
<span className="absolute left-1/2 top-1/2 size-[224px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.05]" />
|
||||
|
||||
<div className={cn("absolute inset-0", SPIN)}>
|
||||
{ORBIT.map(({ key, r, deg, node }) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${r}px)`,
|
||||
}}
|
||||
className="absolute left-1/2 top-1/2 flex size-8 items-center justify-center rounded-full bg-[#161B22] ring-1 ring-white/10"
|
||||
>
|
||||
<span
|
||||
className={cn("flex", SPIN_BACK)}
|
||||
style={{ rotate: `${-deg}deg` }}
|
||||
>
|
||||
{node}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<GradientLogo className="absolute left-1/2 top-1/2 h-auto w-[68px] -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<p className="absolute inset-x-0 bottom-5 text-center text-[13px] font-medium text-[#8b8b8b]">
|
||||
Meet <span className="text-[#4BA0FA]">@supermemory</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRow({
|
||||
date,
|
||||
title,
|
||||
value,
|
||||
current,
|
||||
}: {
|
||||
date: string
|
||||
title: string
|
||||
value?: string
|
||||
current?: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="relative flex items-start gap-3 pl-[18px]">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-0 top-[5px] size-[7px] rounded-full",
|
||||
current
|
||||
? "bg-[#fafafa] ring-4 ring-[#fafafa]/10"
|
||||
: "bg-[#2b3138] ring-1 ring-white/15",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-[13px] font-medium text-[#fafafa]">{date}</span>
|
||||
<span className="text-[12px] leading-snug text-[#8b8b8b]">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{value ? (
|
||||
<span className="shrink-0 text-[14px] font-medium text-[#fafafa] tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepTrial({ onActive }: { onActive: () => void }) {
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
const start = async () => {
|
||||
if (starting) return
|
||||
setStarting(true)
|
||||
analytics.brainTrialCheckoutStarted()
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/trial/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ successUrl: checkoutReturnUrl() }),
|
||||
})
|
||||
const data = (await res.json()) as {
|
||||
checkoutUrl?: string | null
|
||||
status?: string
|
||||
error?: string
|
||||
}
|
||||
if (res.status === 409 || data.error === "trial_unavailable") {
|
||||
throw new Error(
|
||||
"This workspace has already used its free trial. Upgrade from billing to continue.",
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.")
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl
|
||||
return
|
||||
}
|
||||
if (data.status === "already_active" || data.status === "attached") {
|
||||
onActive()
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't start the trial.")
|
||||
} catch (error) {
|
||||
console.error("Failed to start trial:", error)
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Couldn't start the trial.",
|
||||
)
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[22px] leading-tight font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
Start your {TRIAL_DAYS}-day free trial
|
||||
</h2>
|
||||
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
|
||||
Add a payment method to start. You will not be charged today. We
|
||||
will email you before your first payment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="relative flex flex-col gap-5 py-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[3px] top-2.5 bottom-[22px] w-px bg-white/10"
|
||||
/>
|
||||
<TimelineRow
|
||||
date="Today"
|
||||
title="Full access to Company Brain"
|
||||
value="$0"
|
||||
current
|
||||
/>
|
||||
<TimelineRow
|
||||
date={dayOffset(REMINDER_DAY)}
|
||||
title="We email you before the charge"
|
||||
/>
|
||||
<TimelineRow
|
||||
date={dayOffset(TRIAL_DAYS)}
|
||||
title="Trial ends"
|
||||
value={`${MONTHLY_PRICE}/mo`}
|
||||
/>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={start}
|
||||
disabled={starting}
|
||||
className="w-full justify-center rounded-full px-5 py-[11px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{starting ? (
|
||||
<>
|
||||
Opening checkout…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start free trial
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Secured by Stripe · Cancel in one click
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrainPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -394,11 +394,6 @@ export function SelectSpacesModal({
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
Copy,
|
||||
LoaderIcon,
|
||||
ChevronDown,
|
||||
Users,
|
||||
|
|
@ -458,10 +459,26 @@ export default function Account({
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
"flex items-center gap-1 text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Organization
|
||||
{org?.id ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy organization ID"
|
||||
title={org.id}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(org.id)
|
||||
.then(() => toast.success("Organization ID copied"))
|
||||
.catch(() => toast.error("Couldn't copy"))
|
||||
}}
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors hover:text-[#FAFAFA]"
|
||||
>
|
||||
<Copy className="size-2.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{isEditingOrgName ? (
|
||||
<form
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
|
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
features: [
|
||||
"Pay-as-you-go after $5 runs out",
|
||||
"Full search and memory access",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Email support",
|
||||
],
|
||||
},
|
||||
|
|
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
features: [
|
||||
"Auto top-up when balance runs low",
|
||||
"Google Drive, Notion, OneDrive & Granola connectors",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
|
|
@ -203,8 +204,24 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
|
|||
},
|
||||
]
|
||||
|
||||
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
|
||||
// Company Brain workspaces sell Max / Scale / Enterprise (no Free, Pro).
|
||||
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
price: "$100",
|
||||
period: "/mo",
|
||||
credits: "$130",
|
||||
productId: "api_max",
|
||||
description: "Company Brain for teams with everyday usage",
|
||||
mostPopular: true,
|
||||
features: [
|
||||
"Company Brain Slack agent & shared memory",
|
||||
"$130 monthly usage credits",
|
||||
"Unlimited seats",
|
||||
"Auto top-up & spend caps",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
|
|
@ -212,13 +229,13 @@ const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
|||
period: "/mo",
|
||||
credits: "$600",
|
||||
productId: "api_scale",
|
||||
description: "Company Brain for your team, with production usage",
|
||||
mostPopular: true,
|
||||
description: "Company Brain for production workloads",
|
||||
includesFrom: "Max",
|
||||
features: [
|
||||
"Company Brain Slack agent & shared memory",
|
||||
"$600 monthly usage credits when paid",
|
||||
"Auto top-up & spend caps",
|
||||
"Team connectors & dedicated support",
|
||||
"$600 monthly usage credits",
|
||||
"GitHub, S3 & Web Crawler connectors",
|
||||
"Restricted access, container tags & User Insights",
|
||||
"Dedicated support",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -515,6 +532,7 @@ export default function Billing() {
|
|||
const queryClient = useQueryClient()
|
||||
const { user, org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const posthog = usePostHog()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const brainTrial = useMemo(
|
||||
|
|
@ -682,8 +700,10 @@ export default function Billing() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#billing`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||
return
|
||||
|
|
@ -914,6 +934,27 @@ export default function Billing() {
|
|||
)
|
||||
}
|
||||
|
||||
// The trial runs on api_scale, so Max ranks below the current plan and would
|
||||
// otherwise render as a dead "Included with Scale" button. Trial users are
|
||||
// exactly who we want on Max, so it needs its own actionable path.
|
||||
if (plan.id === "max" && (isOnTrial || isBrainTrialEnded)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpgrade("api_max")}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
|
||||
)}
|
||||
>
|
||||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||||
Activate Max
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
|
||||
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
|
||||
return (
|
||||
|
|
@ -1471,6 +1512,20 @@ export default function Billing() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
<div className="md:col-span-2 space-y-2 rounded-lg border border-white/[0.08] bg-white/[0.02] p-4 text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{isOnTrial ? (
|
||||
<p>
|
||||
Your trial runs on Scale. Moving to Max keeps the agent,
|
||||
shared memory and unlimited seats, and drops the GitHub, S3
|
||||
and Web Crawler connectors, restricted access and container
|
||||
tags, and User Insights.
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
Using more than about $400 of credits a month? Scale works out
|
||||
cheaper than Max plus top-ups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
Radar,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -35,6 +36,8 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { configureSectionToPath } from "@/lib/configure-routes"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
|
|
@ -301,6 +304,9 @@ function AutomationCard({
|
|||
id,
|
||||
channels,
|
||||
ownerLabel,
|
||||
personalOnlyApps = [],
|
||||
isAdmin = false,
|
||||
appCatalog = {},
|
||||
onDone,
|
||||
onCancelNew,
|
||||
onCollapse,
|
||||
|
|
@ -309,6 +315,9 @@ function AutomationCard({
|
|||
id: string | null
|
||||
channels: Channel[]
|
||||
ownerLabel?: string
|
||||
personalOnlyApps?: string[]
|
||||
isAdmin?: boolean
|
||||
appCatalog?: Record<string, { name: string; iconDomain?: string }>
|
||||
onDone: () => void
|
||||
onCancelNew?: () => void
|
||||
onCollapse?: () => void
|
||||
|
|
@ -349,9 +358,18 @@ function AutomationCard({
|
|||
const b = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(b.error ?? "Couldn't save.")
|
||||
}
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
warnings?: { app: string }[]
|
||||
}
|
||||
return b.warnings ?? []
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (warnings) => {
|
||||
toast.success("Automation saved.")
|
||||
if (warnings.length)
|
||||
toast.warning(
|
||||
`Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`,
|
||||
{ duration: 10000 },
|
||||
)
|
||||
onDone()
|
||||
},
|
||||
onError: (err) =>
|
||||
|
|
@ -619,6 +637,51 @@ function AutomationCard({
|
|||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{draft.deliverTo === "channel" && personalOnlyApps.length > 0 && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-[12px] leading-snug text-amber-300/80">
|
||||
<TooltipProvider>
|
||||
<span className="flex shrink-0 -space-x-1">
|
||||
{personalOnlyApps.map((app) => (
|
||||
<Tooltip key={app}>
|
||||
<TooltipTrigger asChild>
|
||||
{appCatalog[app]?.iconDomain ? (
|
||||
<img
|
||||
alt={appCatalog[app]?.name ?? app}
|
||||
className="size-4 cursor-default select-none rounded-full bg-white/10 ring-1 ring-black/50"
|
||||
src={`https://www.google.com/s2/favicons?domain=${appCatalog[app]?.iconDomain}&sz=32`}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-4 cursor-default select-none items-center justify-center rounded-full bg-amber-500/20 text-[9px] uppercase ring-1 ring-black/50">
|
||||
{app.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{appCatalog[app]?.name ?? app}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
</TooltipProvider>
|
||||
<span className="truncate">
|
||||
only connected to you ·{" "}
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-amber-200"
|
||||
href={configureSectionToPath("tools")}
|
||||
>
|
||||
Connect for workspace
|
||||
</a>{" "}
|
||||
to use here
|
||||
</>
|
||||
) : (
|
||||
"ask an admin to connect it for the workspace"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{id ? (
|
||||
|
|
@ -851,8 +914,14 @@ function PresetCard({
|
|||
export default function CompanyBrainAutomations() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { user, org } = useAuth()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
const queryClient = useQueryClient()
|
||||
const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
|
||||
const [showAllTemplates, setShowAllTemplates] = useState(false)
|
||||
const [actionSlot, setActionSlot] = useState<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
setActionSlot(document.getElementById("configure-section-actions"))
|
||||
}, [])
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const draftKey = useRef(0)
|
||||
const addDraft = (draft: Draft) =>
|
||||
|
|
@ -886,11 +955,15 @@ export default function CompanyBrainAutomations() {
|
|||
const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return [] as string[]
|
||||
if (!res.ok) return [] as { serverSlug: string; userId: string | null }[]
|
||||
const body = (await res.json()) as {
|
||||
connections?: { serverSlug: string }[]
|
||||
connections?: {
|
||||
serverSlug: string
|
||||
userId: string | null
|
||||
status: string
|
||||
}[]
|
||||
}
|
||||
return (body.connections ?? []).map((c) => c.serverSlug)
|
||||
return (body.connections ?? []).filter((c) => c.status === "active")
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
|
@ -899,7 +972,39 @@ export default function CompanyBrainAutomations() {
|
|||
|
||||
const channels = channelsQuery.data ?? []
|
||||
const automations = listQuery.data ?? []
|
||||
const presets = sortPresets(new Set(appsQuery.data ?? []))
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "catalog", "v2"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/mcp-connections/catalog`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok)
|
||||
return {} as Record<string, { name: string; iconDomain?: string }>
|
||||
const body = (await res.json()) as {
|
||||
catalog?: { slug: string; name?: string; iconDomain?: string }[]
|
||||
}
|
||||
return Object.fromEntries(
|
||||
(body.catalog ?? []).map((e) => [
|
||||
e.slug,
|
||||
{ name: e.name ?? e.slug, iconDomain: e.iconDomain },
|
||||
]),
|
||||
)
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
||||
const connections = appsQuery.data ?? []
|
||||
const presets = sortPresets(new Set(connections.map((c) => c.serverSlug)))
|
||||
const sharedApps = new Set(
|
||||
connections.filter((c) => c.userId === null).map((c) => c.serverSlug),
|
||||
)
|
||||
const personalOnlyApps = [
|
||||
...new Set(
|
||||
connections
|
||||
.filter((c) => c.userId !== null && !sharedApps.has(c.serverSlug))
|
||||
.map((c) => c.serverSlug),
|
||||
),
|
||||
]
|
||||
const nameFor = (userId: string | null): string | undefined => {
|
||||
if (!userId) return undefined
|
||||
if (userId === user?.id) return "You"
|
||||
|
|
@ -913,10 +1018,34 @@ export default function CompanyBrainAutomations() {
|
|||
}
|
||||
const usedTitles = new Set(automations.map((a) => a.title))
|
||||
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
|
||||
const shownPresets = showAllTemplates
|
||||
? availablePresets
|
||||
: availablePresets.slice(0, 3)
|
||||
const hiddenTemplateCount = availablePresets.length - shownPresets.length
|
||||
const hasList = automations.length > 0 || drafts.length > 0
|
||||
|
||||
const newAutomationButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
</button>
|
||||
)
|
||||
const newAutomationPortal = actionSlot ? (
|
||||
createPortal(newAutomationButton, actionSlot)
|
||||
) : (
|
||||
<div className="flex justify-end">{newAutomationButton}</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
{newAutomationPortal}
|
||||
<div className="flex flex-col gap-3">
|
||||
{automations.map((a) =>
|
||||
openId === a.id ? (
|
||||
|
|
@ -925,6 +1054,9 @@ export default function CompanyBrainAutomations() {
|
|||
id={a.id}
|
||||
initial={toDraft(a)}
|
||||
channels={channels}
|
||||
personalOnlyApps={personalOnlyApps}
|
||||
isAdmin={isAdmin}
|
||||
appCatalog={catalogQuery.data ?? {}}
|
||||
onDone={() => {
|
||||
setOpenId(null)
|
||||
refresh()
|
||||
|
|
@ -953,6 +1085,9 @@ export default function CompanyBrainAutomations() {
|
|||
id={null}
|
||||
initial={draft}
|
||||
channels={channels}
|
||||
personalOnlyApps={personalOnlyApps}
|
||||
isAdmin={isAdmin}
|
||||
appCatalog={catalogQuery.data ?? {}}
|
||||
onDone={() => {
|
||||
removeDraft(key)
|
||||
refresh()
|
||||
|
|
@ -961,37 +1096,38 @@ export default function CompanyBrainAutomations() {
|
|||
/>
|
||||
))}
|
||||
|
||||
{hasList ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
Templates
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
hasList ? "pt-2" : "",
|
||||
"text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
{showAllTemplates ? "Templates" : "Ideas for your setup"}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availablePresets.map((p) => (
|
||||
{shownPresets.map((p) => (
|
||||
<PresetCard
|
||||
key={p.id}
|
||||
preset={p}
|
||||
onPick={() => addDraft(presetToDraft(p))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hiddenTemplateCount > 0 || showAllTemplates ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
onClick={() => setShowAllTemplates((v) => !v)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
"self-start text-[12px] font-medium text-[#737B87] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
{showAllTemplates
|
||||
? "Show fewer"
|
||||
: `Show all ${availablePresets.length} templates`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -38,6 +38,7 @@ import {
|
|||
getConnectionSubtitle,
|
||||
} from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
|
|
@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) {
|
|||
export default function ConnectionsMCP() {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const router = useRouter()
|
||||
const [removeDialog, setRemoveDialog] = useState<{
|
||||
|
|
@ -552,8 +554,10 @@ export default function ConnectionsMCP() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#connections`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"use client"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
|
||||
import { brainConnectorIcon } from "../brain-connector-icons"
|
||||
import { ConnectorCard, ScopeChip } from "../directory/connector-card"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
let directoryCache: McpDirectoryEntry[] | null = null
|
||||
|
||||
function isDirectoryEntry(value: unknown): value is McpDirectoryEntry {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const entry = value as Partial<McpDirectoryEntry>
|
||||
return (
|
||||
typeof entry.id === "string" &&
|
||||
typeof entry.name === "string" &&
|
||||
(entry.type === "remote" || entry.type === "local") &&
|
||||
(entry.url === null || typeof entry.url === "string") &&
|
||||
typeof entry.auth === "string" &&
|
||||
(entry.note === null || typeof entry.note === "string") &&
|
||||
Array.isArray(entry.categories) &&
|
||||
entry.categories.every((category) => typeof category === "string") &&
|
||||
typeof entry.popularity === "number" &&
|
||||
(entry.iconDomain === null || typeof entry.iconDomain === "string") &&
|
||||
["custom", "unsupported"].includes(entry.setup ?? "") &&
|
||||
(entry.oauthCapability === null ||
|
||||
["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) &&
|
||||
Array.isArray(entry.authMethods) &&
|
||||
entry.authMethods.every((method) =>
|
||||
["oauth", "api-key"].includes(method),
|
||||
) &&
|
||||
["fixed", "tenant", "unavailable", "local"].includes(
|
||||
entry.availability ?? "",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parseDirectory(value: unknown) {
|
||||
if (!value || typeof value !== "object") throw new Error("invalid catalog")
|
||||
const entries = (value as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) {
|
||||
throw new Error("invalid catalog")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function loadDirectory(signal: AbortSignal) {
|
||||
if (directoryCache) return directoryCache
|
||||
const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, {
|
||||
signal,
|
||||
cache: "default",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) throw new Error("catalog request failed")
|
||||
directoryCache = parseDirectory(await response.json())
|
||||
return directoryCache
|
||||
}
|
||||
|
||||
export function useMcpDirectory() {
|
||||
const [entries, setEntries] = useState<McpDirectoryEntry[]>(
|
||||
() => directoryCache ?? [],
|
||||
)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadDirectory(controller.signal)
|
||||
.then((data) => {
|
||||
setEntries(data)
|
||||
setError(false)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return
|
||||
setError(true)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return { entries, error }
|
||||
}
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return value
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function entrySlug(entry: McpDirectoryEntry) {
|
||||
return entry.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
// Mirrors the backend's URL normalization so connection rows match entries.
|
||||
export function normalizeServerUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase()
|
||||
} catch {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
// An entry we can actually take the user through connecting.
|
||||
export function isEntrySetUppable(entry: McpDirectoryEntry) {
|
||||
return (
|
||||
entry.setup !== "unsupported" &&
|
||||
entry.authMethods.length > 0 &&
|
||||
(entry.availability === "fixed" || entry.availability === "tenant")
|
||||
)
|
||||
}
|
||||
|
||||
// Entries worth listing at all — servers with no reachable URL are dropped.
|
||||
export function listableDirectoryEntries(entries: McpDirectoryEntry[]) {
|
||||
return entries.filter((entry) => entry.availability !== "unavailable")
|
||||
}
|
||||
|
||||
export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) {
|
||||
return [entry.name, entry.url, entry.note, ...entry.categories]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (!entry.iconDomain || failed) {
|
||||
return brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={`/api/mcp-icon?domain=${encodeURIComponent(entry.iconDomain)}`}
|
||||
alt=""
|
||||
className="size-5 object-contain"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DirectoryEntryCard({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
const status = connected
|
||||
? "Connected"
|
||||
: canSetUp
|
||||
? "Not connected"
|
||||
: entry.availability === "local"
|
||||
? "Desktop only"
|
||||
: "Coming soon"
|
||||
return (
|
||||
<ConnectorCard
|
||||
icon={<DirectoryIcon entry={entry} />}
|
||||
name={entry.name}
|
||||
subtitle={entrySubtitle(entry)}
|
||||
footerLeft={<ScopeChip label={status} connected={connected} />}
|
||||
footerRight={
|
||||
canSetUp ? (
|
||||
<PillButton onClick={() => onSetUp(entry)}>Set up</PillButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: McpDirectoryEntry) {
|
||||
if (entry.categories.length > 0) {
|
||||
return entry.categories.slice(0, 2).map(categoryLabel).join(" · ")
|
||||
}
|
||||
return entry.type === "local" ? "Desktop extension" : "MCP server"
|
||||
}
|
||||
|
||||
// One directory listing: a dense single-line row. The default state carries no
|
||||
// status text — in a marketplace, "not connected" is implied. Only connection,
|
||||
// or the reason there's no button, earns words.
|
||||
export function DirectoryEntryRow({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[#14161A]">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<DirectoryIcon entry={entry} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="mt-px truncate text-[11px] font-medium text-[#616875]">
|
||||
{entrySubtitle(entry)}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 pr-1 text-[11px] font-medium text-[#FAFAFA]">
|
||||
<span className="size-[6px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : canSetUp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetUp(entry)}
|
||||
className="h-7 shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 text-[12px] font-medium text-[#FAFAFA]/70 transition-colors group-hover:bg-[#252C37] group-hover:text-[#FAFAFA] hover:bg-[#2B3340]"
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 pr-1 text-[11px] font-medium text-[#4E5560]">
|
||||
{entry.availability === "local" ? "Desktop only" : "Coming soon"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GRID_PAGE_SIZE = 24
|
||||
|
||||
// Paged card grid over the MCP directory. With a query it renders matching
|
||||
// servers; without one it renders the whole marketplace.
|
||||
export function McpDirectoryGrid({
|
||||
query = "",
|
||||
entries,
|
||||
loadError,
|
||||
excludeSlugs,
|
||||
isEntryConnected,
|
||||
onSetUp,
|
||||
suppressEmpty,
|
||||
}: {
|
||||
query?: string
|
||||
entries: McpDirectoryEntry[]
|
||||
loadError: boolean
|
||||
// entries already rendered elsewhere (e.g. the built-in app catalog)
|
||||
excludeSlugs?: Set<string>
|
||||
isEntryConnected: (entry: McpDirectoryEntry) => boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
// the caller rendered its own matches, so an empty grid isn't "no results"
|
||||
suppressEmpty?: boolean
|
||||
}) {
|
||||
const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE)
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query
|
||||
useEffect(() => {
|
||||
setVisibleCount(GRID_PAGE_SIZE)
|
||||
}, [needle])
|
||||
|
||||
// Connected first, then connectable, then "coming soon"/desktop-only.
|
||||
const matches = useMemo(() => {
|
||||
const found = entries.filter(
|
||||
(entry) =>
|
||||
!excludeSlugs?.has(entrySlug(entry)) &&
|
||||
(!needle || entryMatchesQuery(entry, needle)),
|
||||
)
|
||||
return found.sort(
|
||||
(a, b) =>
|
||||
Number(isEntryConnected(b)) - Number(isEntryConnected(a)) ||
|
||||
Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)),
|
||||
)
|
||||
}, [entries, excludeSlugs, isEntryConnected, needle])
|
||||
|
||||
if (loadError) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
The MCP directory couldn't be loaded. Refresh to try again.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-[13px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading MCP directory
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
No integrations match “{query.trim()}”.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{matches.slice(0, visibleCount).map((entry) => (
|
||||
<DirectoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
connected={isEntryConnected(entry)}
|
||||
onSetUp={onSetUp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < matches.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + GRID_PAGE_SIZE)}
|
||||
className="mx-auto flex h-9 cursor-pointer items-center rounded-full border border-[#2A313C] px-5 text-[12px] font-semibold text-[#D4D4D8] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]"
|
||||
>
|
||||
Show {Math.min(GRID_PAGE_SIZE, matches.length - visibleCount)} more ·{" "}
|
||||
{visibleCount} of {matches.length.toLocaleString()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) {
|
|||
export function SlackConnectCard() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [status, setStatus] = useState<SlackStatus | null>(null)
|
||||
const [trialActive, setTrialActive] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -58,10 +59,16 @@ export function SlackConnectCard() {
|
|||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
|
||||
const [slackRes, trialRes] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }),
|
||||
])
|
||||
if (!active) return
|
||||
if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus)
|
||||
if (trialRes.ok) {
|
||||
const trial = (await trialRes.json()) as { active?: boolean }
|
||||
setTrialActive(Boolean(trial.active))
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
|
|
@ -92,7 +99,7 @@ export function SlackConnectCard() {
|
|||
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
) : trialActive ? (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
|
|
@ -100,6 +107,13 @@ export function SlackConnectCard() {
|
|||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/onboarding"
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white/10 px-3.5 py-2 text-[13px] font-semibold text-fg-primary ring-1 ring-surface-border transition-colors hover:bg-white/15"
|
||||
>
|
||||
Finish setting up
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
41
apps/web/components/trial-setup-banner.tsx
Normal file
41
apps/web/components/trial-setup-banner.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client"
|
||||
|
||||
import { ArrowRight, CreditCard } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
|
||||
export function TrialSetupBanner() {
|
||||
const { needsSetup, data } = useTrialStatus()
|
||||
if (!needsSetup) return null
|
||||
|
||||
const endedTrial = data?.reason === "trial_ended"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] px-4 py-3 ring-1 ring-[#4BA0FA]/20 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]/12">
|
||||
<CreditCard className="size-4 text-[#4BA0FA]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-fg-primary">
|
||||
{endedTrial
|
||||
? "Your Company Brain trial has ended"
|
||||
: "Finish setting up Company Brain"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
|
||||
{endedTrial
|
||||
? "Move to Max or Scale to switch the brain back on."
|
||||
: "Add a card to start your 14-day trial. $0 today."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={endedTrial ? "/?settings=billing" : "/onboarding"}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{endedTrial ? "Upgrade" : "Add card"}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,8 @@
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
getBrainMode,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "@/lib/billing-utils"
|
||||
import { isCompanyBrainOrg } from "@/lib/billing-utils"
|
||||
|
||||
export function useHasCompanyBrain(): boolean {
|
||||
const { org } = useAuth()
|
||||
const metadata = org?.metadata as Record<string, unknown> | string | undefined
|
||||
// An explicit concierge override wins over the team-onboarding fallback.
|
||||
const override = getCompanyBrainOverride(metadata)
|
||||
if (override !== undefined) return override
|
||||
// Team-brain orgs use brain spaces even before the add-on webhook lands.
|
||||
return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
|
||||
return isCompanyBrainOrg(metadata)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ export function useConnectorAccess(opts?: { enabled?: boolean }) {
|
|||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
const hasScale =
|
||||
enabled && hasActivePlan(autumn.data?.subscriptions, "api_scale")
|
||||
return {
|
||||
hasPro,
|
||||
hasMax,
|
||||
hasScale,
|
||||
hasCompanyBrain,
|
||||
connectorAccess: hasPro || hasCompanyBrain,
|
||||
loading: enabled && autumn.isLoading,
|
||||
|
|
|
|||
82
apps/web/hooks/use-promo-code.ts
Normal file
82
apps/web/hooks/use-promo-code.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
|
||||
const PROMO_TOAST_ID = "promo-code"
|
||||
|
||||
function promoCodeKey(orgId: string): string {
|
||||
return `sm.promoCode.org_${orgId}`
|
||||
}
|
||||
|
||||
function readOrgPromoCode(orgId?: string): string | null {
|
||||
if (!orgId || typeof window === "undefined") return null
|
||||
return window.localStorage.getItem(promoCodeKey(orgId))
|
||||
}
|
||||
|
||||
export function usePromoCode() {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id
|
||||
|
||||
const getDiscounts = useCallback(() => {
|
||||
const promotionCode = readOrgPromoCode(orgId)
|
||||
return promotionCode ? [{ promotionCode }] : undefined
|
||||
}, [orgId])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (!orgId) return
|
||||
window.localStorage.removeItem(promoCodeKey(orgId))
|
||||
toast.dismiss(PROMO_TOAST_ID)
|
||||
}, [orgId])
|
||||
|
||||
return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
|
||||
}
|
||||
|
||||
export function PromoCodeCapture() {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get("discountCode")
|
||||
if (!code) return
|
||||
|
||||
window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code)
|
||||
url.searchParams.delete("discountCode")
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function PromoCodeHost() {
|
||||
const { org } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!org?.id) return
|
||||
|
||||
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
|
||||
if (pending) {
|
||||
window.localStorage.setItem(promoCodeKey(org.id), pending)
|
||||
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
|
||||
}
|
||||
|
||||
const code = readOrgPromoCode(org.id)
|
||||
if (!code) {
|
||||
toast.dismiss(PROMO_TOAST_ID)
|
||||
return
|
||||
}
|
||||
toast.success("Discount code active", {
|
||||
id: PROMO_TOAST_ID,
|
||||
description: `Code ${code} will apply at checkout.`,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings#billing"),
|
||||
},
|
||||
})
|
||||
}, [org?.id, router])
|
||||
|
||||
return null
|
||||
}
|
||||
34
apps/web/hooks/use-trial-status.ts
Normal file
34
apps/web/hooks/use-trial-status.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
export type TrialStatus = {
|
||||
active: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
/** Distinguishes a named Company Brain org from one whose trial is actually live. */
|
||||
export function useTrialStatus() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["brain", "trial-status"],
|
||||
queryFn: async (): Promise<TrialStatus> => {
|
||||
const res = await fetch(`${BACKEND}/brain/trial/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load trial status")
|
||||
const data = (await res.json()) as { active?: boolean; reason?: string }
|
||||
return { active: Boolean(data.active), reason: data.reason ?? null }
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
needsSetup: isCompanyBrain && query.data ? !query.data.active : false,
|
||||
}
|
||||
}
|
||||
|
|
@ -271,4 +271,9 @@ export const analytics = {
|
|||
}) => safeCapture("company_brain_promo_clicked", props),
|
||||
companyBrainPromoDismissed: () =>
|
||||
safeCapture("company_brain_promo_dismissed"),
|
||||
|
||||
brainTrialCardViewed: () => safeCapture("brain_trial_card_viewed"),
|
||||
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
|
||||
brainTrialCheckoutAbandoned: () =>
|
||||
safeCapture("brain_trial_checkout_abandoned"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,15 @@ export function getBrainMode(
|
|||
: null
|
||||
}
|
||||
|
||||
export function isCompanyBrainOrg(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(metadataRaw)
|
||||
if (override !== undefined) return override
|
||||
if (hasCompanyBrain(metadataRaw)) return true
|
||||
return getBrainMode(metadataRaw) === "team"
|
||||
}
|
||||
|
||||
export type BrainTrialStatus =
|
||||
| "active"
|
||||
| "exhausted"
|
||||
|
|
@ -185,18 +194,26 @@ export function getBrainTrialInfo(
|
|||
}
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffix for display
|
||||
* Format a number with K/M/B suffix for display
|
||||
* @example formatUsageNumber(1500000) => "1.5M"
|
||||
* @example formatUsageNumber(50000) => "50K"
|
||||
* @example formatUsageNumber(999950) => "1.0M"
|
||||
*/
|
||||
export function formatUsageNumber(value: number): string {
|
||||
const withSuffix = (n: number, suffix: string) =>
|
||||
n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}`
|
||||
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000
|
||||
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
|
||||
return millions >= 999.95
|
||||
? withSuffix(value / 1_000_000_000, "B")
|
||||
: withSuffix(millions, "M")
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000
|
||||
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
|
||||
return thousands >= 999.95
|
||||
? withSuffix(value / 1_000_000, "M")
|
||||
: withSuffix(thousands, "K")
|
||||
}
|
||||
return value.toString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { ModelId } from "@/lib/models"
|
||||
|
||||
const OTHER_MODELS: ModelId[] = [
|
||||
"gpt-5.1",
|
||||
"claude-sonnet-4.6",
|
||||
"gemini-2.5-pro",
|
||||
"gpt-5.6-terra",
|
||||
"claude-sonnet-5",
|
||||
"gemini-3.1-pro-preview",
|
||||
]
|
||||
|
||||
function flattenError(e: unknown): string {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
getBrainMode,
|
||||
getBrainWorkspaceDomain,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "./billing-utils"
|
||||
import { getBrainWorkspaceDomain, isCompanyBrainOrg } from "./billing-utils"
|
||||
|
||||
export type BrainEntryOrganization = {
|
||||
id: string
|
||||
|
|
@ -18,21 +13,12 @@ export type CompanyBrainEntryDecision =
|
|||
| { action: "choose"; organizations: BrainEntryOrganization[] }
|
||||
| { action: "create" }
|
||||
|
||||
export function isCompanyBrainOrganization(
|
||||
organization: BrainEntryOrganization,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(organization.metadata)
|
||||
if (override !== undefined) return override
|
||||
return (
|
||||
hasCompanyBrain(organization.metadata) ||
|
||||
getBrainMode(organization.metadata) === "team"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCompanyBrainOrganizations(
|
||||
organizations: BrainEntryOrganization[],
|
||||
): BrainEntryOrganization[] {
|
||||
return organizations.filter(isCompanyBrainOrganization)
|
||||
return organizations.filter((organization) =>
|
||||
isCompanyBrainOrg(organization.metadata),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDomain(domain: string): string {
|
||||
|
|
|
|||
21
apps/web/lib/mcp-directory.ts
Normal file
21
apps/web/lib/mcp-directory.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type McpDirectoryAvailability =
|
||||
| "fixed"
|
||||
| "tenant"
|
||||
| "unavailable"
|
||||
| "local"
|
||||
|
||||
export type McpDirectoryEntry = {
|
||||
id: string
|
||||
name: string
|
||||
type: "remote" | "local"
|
||||
url: string | null
|
||||
auth: string
|
||||
note: string | null
|
||||
categories: string[]
|
||||
popularity: number
|
||||
availability: McpDirectoryAvailability
|
||||
iconDomain: string | null
|
||||
setup: "custom" | "unsupported"
|
||||
oauthCapability: "dcr" | "preregistered" | null
|
||||
authMethods: Array<"oauth" | "api-key">
|
||||
}
|
||||
518
apps/web/lib/mcp-icon-domains.json
Normal file
518
apps/web/lib/mcp-icon-domains.json
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
{
|
||||
"domains": [
|
||||
"10xgenomics.com",
|
||||
"activecampaign.com",
|
||||
"actively.ai",
|
||||
"adisinsight-mcp.springer.com",
|
||||
"adobe-creativity.adobe.io",
|
||||
"adobeaemcloud.com",
|
||||
"aep-ai-ama.adobe.io",
|
||||
"affinity.co",
|
||||
"aftership.com",
|
||||
"agent.thoughtspot.app",
|
||||
"agentmail.to",
|
||||
"agents.riskanalytics.dnb.com",
|
||||
"agenttools.wolfram.com",
|
||||
"ahrefs.com",
|
||||
"ai-connect.norton.com",
|
||||
"ai-inc.mailchimp.com",
|
||||
"ai-inc.quickbooks.intuit.com",
|
||||
"ai-inc.turbotax.intuit.com",
|
||||
"ai-tools.tillermoney.com",
|
||||
"ai.chronograph.pe",
|
||||
"ai.consilio.com",
|
||||
"ai.thirdbridge.com",
|
||||
"ai.todoist.net",
|
||||
"ai.veltra.com",
|
||||
"airbnb.com",
|
||||
"airtable.com",
|
||||
"ajo-mcp.adobe.io",
|
||||
"alltrails.com",
|
||||
"alma.food",
|
||||
"alphavantage.co",
|
||||
"alphaxiv.org",
|
||||
"alpic.ai",
|
||||
"amplitude.com",
|
||||
"analytics.credit.morningstar.com",
|
||||
"analytics.lseg.com",
|
||||
"android.com",
|
||||
"angellist.com",
|
||||
"anthropic.mcp.creditkarma.com",
|
||||
"api-ssl.bitly.com",
|
||||
"apify.com",
|
||||
"apigw.americanexpress.com",
|
||||
"apollo.io",
|
||||
"apollographql.com",
|
||||
"app.airops.com",
|
||||
"app.base44.com",
|
||||
"app.brighthire.ai",
|
||||
"app.carta.com",
|
||||
"app.definely.com",
|
||||
"app.eraser.io",
|
||||
"app.files.com",
|
||||
"app.flourish.studio",
|
||||
"app.fyxer.com",
|
||||
"app.grasp-ai.com",
|
||||
"app.hanoverpark.com",
|
||||
"app.ketryx.com",
|
||||
"app.magicschool.ai",
|
||||
"app.midpage.ai",
|
||||
"app.synthesize.bio",
|
||||
"app.tropicapp.io",
|
||||
"app.unthread.io",
|
||||
"appfolio.com",
|
||||
"asana.com",
|
||||
"ashbyhq.com",
|
||||
"asset-management.mcp.cloudinary.com",
|
||||
"atlassian.com",
|
||||
"attention.tech",
|
||||
"attio.com",
|
||||
"audible.com",
|
||||
"auraintelligence.com",
|
||||
"autodesk.com",
|
||||
"autorfp.ai",
|
||||
"benchling.com",
|
||||
"benevity.org",
|
||||
"bigdata.com",
|
||||
"bigquery.googleapis.com",
|
||||
"bindings.mcp.cloudflare.com",
|
||||
"blockscout.com",
|
||||
"blueconic.com",
|
||||
"boltz.bio",
|
||||
"box.com",
|
||||
"brandfetch.io",
|
||||
"brave.com",
|
||||
"braze.com",
|
||||
"brevo.com",
|
||||
"brex.com",
|
||||
"briskteaching.com",
|
||||
"calendar.google.com",
|
||||
"calendly.com",
|
||||
"callbacks.omniapp.co",
|
||||
"canary-data.com",
|
||||
"candid.org",
|
||||
"canva.com",
|
||||
"cargoai.co",
|
||||
"cbinsights.com",
|
||||
"chargebee.com",
|
||||
"chartmogul.com",
|
||||
"chatgpt.mermaid.ai",
|
||||
"checkatrade.com",
|
||||
"circleback.ai",
|
||||
"civitatis-claude-app.civitatis.com",
|
||||
"cja-mcp.adobe.io",
|
||||
"clapi.guidepoint.io",
|
||||
"clarify.ai",
|
||||
"clarity-sfdr20-mcp.pro.clarity.ai",
|
||||
"claude-mcp-api.ml.goodnotes.com",
|
||||
"claude.mcp.kpler.com",
|
||||
"claude.slidesgpt.com",
|
||||
"claudecompanion.gateway.api.mcafee.com",
|
||||
"clay.com",
|
||||
"clerk.com",
|
||||
"clickhouse.cloud",
|
||||
"clickup.com",
|
||||
"close.com",
|
||||
"cloud.cdata.com",
|
||||
"cloudimanage.com",
|
||||
"cloze.com",
|
||||
"cognitoforms.com",
|
||||
"coindesk.com",
|
||||
"columnapi.com",
|
||||
"cometchat.com",
|
||||
"commonroom.io",
|
||||
"compute.googleapis.com",
|
||||
"connect.squareup.com",
|
||||
"connector.scholargateway.ai",
|
||||
"consensus.app",
|
||||
"contentsquare.com",
|
||||
"context.era.app",
|
||||
"context7.com",
|
||||
"coralogix.com",
|
||||
"coteach.ai",
|
||||
"coupler.io",
|
||||
"coursera.com",
|
||||
"courtlistener.com",
|
||||
"courtroom5.com",
|
||||
"craft.do",
|
||||
"crossbeam.com",
|
||||
"crypto.com",
|
||||
"customer.io",
|
||||
"daloopa.com",
|
||||
"dashboard.plaid.com",
|
||||
"data-search.apigw.feverup.com",
|
||||
"databricks.com",
|
||||
"datacamp.com",
|
||||
"datadoghq.com",
|
||||
"datagrail.io",
|
||||
"datahub.com",
|
||||
"day.ai",
|
||||
"deepl.com",
|
||||
"demandapi-mcp.booking.com",
|
||||
"descript.com",
|
||||
"descrybe.com",
|
||||
"developer.api.autodesk.com",
|
||||
"developer.mcp.mastercard.com",
|
||||
"devrev.ai",
|
||||
"dhsprogram.com",
|
||||
"dice.com",
|
||||
"diffit.me",
|
||||
"digits.com",
|
||||
"directbooker.ai",
|
||||
"docs.superhuman.com",
|
||||
"docuseal.com",
|
||||
"docusign.com",
|
||||
"dovetail.com",
|
||||
"dremio.com",
|
||||
"drive.google.com",
|
||||
"dropbox.com",
|
||||
"dynatrace.com",
|
||||
"econ-index.mcp.claude.com",
|
||||
"elevenlabs.io",
|
||||
"elicit.com",
|
||||
"entendre.finance",
|
||||
"eulerapp.com",
|
||||
"everlaw.com",
|
||||
"exa.ai",
|
||||
"example-server.modelcontextprotocol.io",
|
||||
"excalidraw.com",
|
||||
"exp-app-mcp.prod.ep.viator.com",
|
||||
"expedia.com",
|
||||
"expo.dev",
|
||||
"factset.com",
|
||||
"fathom.ai",
|
||||
"fellow.app",
|
||||
"felt.com",
|
||||
"fids-mcp.ice.com",
|
||||
"fig-mcp.instacart.com",
|
||||
"figma.com",
|
||||
"financeanalytics.dnb.com",
|
||||
"financialmodelingprep.com",
|
||||
"fireflies.ai",
|
||||
"firefox.com",
|
||||
"fiscal.ai",
|
||||
"fitch.group",
|
||||
"floot.com",
|
||||
"frontify-integrations.com",
|
||||
"fullstory.com",
|
||||
"funnel.io",
|
||||
"g.runorion.com",
|
||||
"g2.com",
|
||||
"gainsight.com",
|
||||
"gamma.app",
|
||||
"gatewaymcp.verisk.com",
|
||||
"genai-prod-ext.dominos.co.in",
|
||||
"getaugust.ai",
|
||||
"getguru.com",
|
||||
"getmontecarlo.com",
|
||||
"getunblocked.com",
|
||||
"glean.com",
|
||||
"global.datasite.com",
|
||||
"glovoapp.com",
|
||||
"gmail.com",
|
||||
"gocardless.com",
|
||||
"godaddy.com",
|
||||
"gopigment.com",
|
||||
"govcon.dev",
|
||||
"govtribe.com",
|
||||
"grain.com",
|
||||
"granola.ai",
|
||||
"grantedai.com",
|
||||
"grasshopper-mcp.prd.narmitech.com",
|
||||
"grounding.kensho.com",
|
||||
"gusto.com",
|
||||
"harmonic.ai",
|
||||
"harness.io",
|
||||
"harvey.ai",
|
||||
"haveibeenpwned.com",
|
||||
"hcls.mcp.claude.com",
|
||||
"healthex.io",
|
||||
"helium10.com",
|
||||
"heygen.com",
|
||||
"highspot.com",
|
||||
"honeycomb.io",
|
||||
"hrn-production.helix.com",
|
||||
"hubspot.com",
|
||||
"huggingface.co",
|
||||
"ibisworld.com",
|
||||
"ibkr.com",
|
||||
"idiolect.app",
|
||||
"ifttt.com",
|
||||
"imedidata.com",
|
||||
"incident.io",
|
||||
"indeed.com",
|
||||
"inductive.bio",
|
||||
"inkbox.ai",
|
||||
"insiderone.com",
|
||||
"instrumentl.com",
|
||||
"intapp.com",
|
||||
"integrators.prod.api.tabsplatform.com",
|
||||
"intercom.com",
|
||||
"ipone.clarivate.com",
|
||||
"ironcladapp.com",
|
||||
"isometric.com",
|
||||
"item.app",
|
||||
"jam.dev",
|
||||
"jentic.com",
|
||||
"jotform.com",
|
||||
"jupiterone.com",
|
||||
"jusmundi.com",
|
||||
"k.owkin.com",
|
||||
"kfinance.kensho.com",
|
||||
"kg.mcp.learningcommons.org",
|
||||
"kindora-mcp.azurewebsites.net",
|
||||
"kiwi.com",
|
||||
"klaviyo.com",
|
||||
"krisp.ai",
|
||||
"kubernetes.io",
|
||||
"lastminute.com",
|
||||
"latch.bio",
|
||||
"latticehq.com",
|
||||
"lawve.ai",
|
||||
"learn.microsoft.com",
|
||||
"leaveadot.com",
|
||||
"legal-mcp.thomsonreuters.com",
|
||||
"legaldatahunter.com",
|
||||
"legalzoom.com",
|
||||
"letsbot.net",
|
||||
"letsdeel.com",
|
||||
"light.inc",
|
||||
"lightfield.app",
|
||||
"lilt.com",
|
||||
"linear.app",
|
||||
"listenlabs.ai",
|
||||
"litmus.com",
|
||||
"livestorm.co",
|
||||
"localfalcon.com",
|
||||
"lorikeetcx.ai",
|
||||
"lovable.dev",
|
||||
"lucid.app",
|
||||
"luminpdf.com",
|
||||
"lumonic.com",
|
||||
"lunarcrush.ai",
|
||||
"lusha.com",
|
||||
"macaly.com",
|
||||
"magicpatterns.com",
|
||||
"mail.superhuman.com",
|
||||
"mailerlite.com",
|
||||
"make.com",
|
||||
"manufact.com",
|
||||
"marketplace-mcp.us-east-1.api.aws",
|
||||
"matrixmcp.virtuoso.ai",
|
||||
"mcp-app.turkishtechlab.com",
|
||||
"mcp-demo.airwallex.com",
|
||||
"mcp-gateway-external-pilot.spotify.net",
|
||||
"mcp-pub.aiera.com",
|
||||
"mcp-public.basecamp-research.com",
|
||||
"mcp-server.egnyte.com",
|
||||
"mcp-server.signnow.com",
|
||||
"mcp-server.zomato.com",
|
||||
"mcp-v1.tixel.com",
|
||||
"mcp2.readwise.io",
|
||||
"meetcampfire.com",
|
||||
"melon.com",
|
||||
"meltwater.com",
|
||||
"mem.ai",
|
||||
"mem0.ai",
|
||||
"mercadolibre.com",
|
||||
"mercury.com",
|
||||
"metabase.com",
|
||||
"metal.ai",
|
||||
"metaview.ai",
|
||||
"microsoft.com",
|
||||
"mintlify.com",
|
||||
"miro.com",
|
||||
"mixpanel.com",
|
||||
"monday.com",
|
||||
"mongodb.com",
|
||||
"moodys.com",
|
||||
"morningstar.com",
|
||||
"mospi.gov.in",
|
||||
"motherduck.com",
|
||||
"msci.com",
|
||||
"mtnewswires.com",
|
||||
"myisolved.com",
|
||||
"n8n.io",
|
||||
"netlify-mcp.netlify.app",
|
||||
"netsuite.com",
|
||||
"nimbleway.com",
|
||||
"nlp.api.production.unwrap.ai",
|
||||
"nooks.in",
|
||||
"notion.com",
|
||||
"omni.mulesoft.com",
|
||||
"onesignal.com",
|
||||
"ontra.ai",
|
||||
"open-ai-app.stubhub.net",
|
||||
"oreilly.com",
|
||||
"otter.ai",
|
||||
"ottotheagent.com",
|
||||
"outreach.io",
|
||||
"pagerduty.com",
|
||||
"pandadoc.com",
|
||||
"partner-mcp.ticketmaster.com",
|
||||
"patlytics.ai",
|
||||
"paypal.com",
|
||||
"paytmpayments.com",
|
||||
"peec.ai",
|
||||
"pga.com",
|
||||
"phished.io",
|
||||
"phoenix.hginsights.com",
|
||||
"pi.security",
|
||||
"pinegap.ai",
|
||||
"platform.opentargets.org",
|
||||
"plaud.ai",
|
||||
"playmcp.kakao.com",
|
||||
"polaranalytics.com",
|
||||
"pophive.org",
|
||||
"posthog.com",
|
||||
"postman.com",
|
||||
"premium.mcp.pitchbook.com",
|
||||
"privacy.com",
|
||||
"process.st",
|
||||
"prod.originhq.com",
|
||||
"production.ai-mcp-extensibility-prd.tamg.cloud",
|
||||
"projects.motionapp.com",
|
||||
"pscale.dev",
|
||||
"public-api.wordpress.com",
|
||||
"pubmed.mcp.claude.com",
|
||||
"qbo-connector.meridian.pilot.com",
|
||||
"qonto.com",
|
||||
"quartr.com",
|
||||
"quicknode.com",
|
||||
"quo.com",
|
||||
"railway.com",
|
||||
"rallyuxr.com",
|
||||
"ramp-mcp-remote.ramp.com",
|
||||
"ramp.com",
|
||||
"rapid7.com",
|
||||
"razorpay.com",
|
||||
"react.dev",
|
||||
"read.ai",
|
||||
"reclaim.ai",
|
||||
"reddit.com",
|
||||
"relativity.com",
|
||||
"remote.com",
|
||||
"render.com",
|
||||
"replit-mcp.com",
|
||||
"resend.com",
|
||||
"retool.com",
|
||||
"revolut.com",
|
||||
"rillet.com",
|
||||
"roamresearch.com",
|
||||
"roboflow.com",
|
||||
"salesflare.com",
|
||||
"salesloft.com",
|
||||
"sanity.io",
|
||||
"sap.com",
|
||||
"scamguard.malwarebytes.com",
|
||||
"scite.ai",
|
||||
"seismic.com",
|
||||
"semrush.com",
|
||||
"send.co",
|
||||
"sentry.dev",
|
||||
"servicenow.com",
|
||||
"services.biorender.com",
|
||||
"services.functionhealth.com",
|
||||
"services.oxfordeconomics.com",
|
||||
"setup.shopify.com",
|
||||
"shapes.co",
|
||||
"shipbob.com",
|
||||
"shippo.com",
|
||||
"shutterstock.com",
|
||||
"sigmacomputing.com",
|
||||
"signeasy.com",
|
||||
"similarweb.com",
|
||||
"sketch.com",
|
||||
"sketchup.com",
|
||||
"slack.com",
|
||||
"smartbear.com",
|
||||
"smartling.com",
|
||||
"smartsheet.com",
|
||||
"snowflake.com",
|
||||
"snowstorm-mcp.snomedtools.org",
|
||||
"snyk.io",
|
||||
"solveintelligence.com",
|
||||
"sourcegraph.com",
|
||||
"spinach.ai",
|
||||
"splice.com",
|
||||
"sprouts-mcp-server.kartikay-dhar.workers.dev",
|
||||
"squareup.com",
|
||||
"stackoverflow.com",
|
||||
"staircase.ai",
|
||||
"starburst.io",
|
||||
"strava.com",
|
||||
"stripe.com",
|
||||
"stytch.dev",
|
||||
"sumble.com",
|
||||
"sumsub.com",
|
||||
"supabase.com",
|
||||
"super.com",
|
||||
"supermetrics.com",
|
||||
"surveymonkey.com",
|
||||
"swagger.mcp.smartbear.com",
|
||||
"sybill.ai",
|
||||
"synapse.org",
|
||||
"tableau.com",
|
||||
"taskrabbit.com",
|
||||
"tavily.com",
|
||||
"taxact.com",
|
||||
"teacher-tools.eedi.ai",
|
||||
"teamtailor.com",
|
||||
"techgc.co",
|
||||
"tellme.embat.io",
|
||||
"thumbtack.com",
|
||||
"tickettailor.ai",
|
||||
"ticktick.com",
|
||||
"tigerdata.com",
|
||||
"tines.com",
|
||||
"tldraw-mcp-app.tldraw.workers.dev",
|
||||
"tldv.io",
|
||||
"tomtom.com",
|
||||
"tray.io",
|
||||
"trellis.law",
|
||||
"trello.com",
|
||||
"trivago.com",
|
||||
"tryprofound.com",
|
||||
"turquoise.health",
|
||||
"twilio.com",
|
||||
"uakozrqrztgrgwoywxkx.supabase.co",
|
||||
"uber.com",
|
||||
"ubereats.com",
|
||||
"udemy.com",
|
||||
"unsplash.com",
|
||||
"use.kick.co",
|
||||
"usepylon.com",
|
||||
"v0.app",
|
||||
"vast.blueskyapi.com",
|
||||
"vendr.com",
|
||||
"vercel.com",
|
||||
"vibe.com",
|
||||
"virtuoso.ai",
|
||||
"voluum.com",
|
||||
"webexapis.com",
|
||||
"webflow.com",
|
||||
"webull.com",
|
||||
"whimsical.com",
|
||||
"windsor.ai",
|
||||
"wisdom-api.enterpret.com",
|
||||
"wisprflow.ai",
|
||||
"within.ai",
|
||||
"wix.com",
|
||||
"workable.com",
|
||||
"workato.com",
|
||||
"workfront.adobe.com",
|
||||
"workos.com",
|
||||
"wrike.com",
|
||||
"wyndhamhotels.com",
|
||||
"xactrestore-xactremodelserver-usw2-prod.propsol.io",
|
||||
"xero.com",
|
||||
"xweather.com",
|
||||
"zapier.com",
|
||||
"ziprecruiter.com",
|
||||
"zocks.io",
|
||||
"zoho.com",
|
||||
"zoom.us",
|
||||
"zoominfo.com",
|
||||
"zscaler.com"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
export const models = [
|
||||
{
|
||||
id: "grok-4.3",
|
||||
name: "Grok 4.3",
|
||||
id: "grok-4.5",
|
||||
name: "Grok 4.5",
|
||||
description: "xAI's latest model",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1",
|
||||
name: "GPT 5.1",
|
||||
id: "gpt-5.6-terra",
|
||||
name: "GPT 5.6",
|
||||
description: "OpenAI's latest model",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
description: "Anthropic's advanced model",
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 3 Pro",
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro",
|
||||
description: "Google's most capable model",
|
||||
},
|
||||
] as const
|
||||
|
|
@ -25,10 +25,10 @@ export type ModelId = (typeof models)[number]["id"]
|
|||
export type ReasoningEffort = "instant" | "thinking"
|
||||
|
||||
export const modelNames: Record<ModelId, { name: string; version: string }> = {
|
||||
"grok-4.3": { name: "Grok", version: "4.3" },
|
||||
"gpt-5.1": { name: "GPT", version: "5.1" },
|
||||
"claude-sonnet-4.6": { name: "Claude", version: "4.6" },
|
||||
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
|
||||
"grok-4.5": { name: "Grok", version: "4.5" },
|
||||
"gpt-5.6-terra": { name: "GPT", version: "5.6" },
|
||||
"claude-sonnet-5": { name: "Claude", version: "Sonnet 5" },
|
||||
"gemini-3.1-pro-preview": { name: "Gemini", version: "3.1 Pro" },
|
||||
}
|
||||
|
||||
export const reasoningOptions: Array<{
|
||||
|
|
|
|||
|
|
@ -41,13 +41,14 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// MCP setup page is public — no auth required
|
||||
if (url.searchParams.get("view") === "mcp") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Integrations index is public in guest mode; actions still require login.
|
||||
if (url.pathname === "/" && url.searchParams.get("view") === "integrations") {
|
||||
// Integrations index and MCP setup are public in guest mode; actions still
|
||||
// require login. The ?view param is only meaningful at "/" (see
|
||||
// lib/view-mode-context, which ignores it elsewhere), so scope it there —
|
||||
// unscoped, ?view=mcp would let any path skip the /api/ gate below.
|
||||
if (
|
||||
url.pathname === "/" &&
|
||||
["integrations", "mcp"].includes(url.searchParams.get("view") ?? "")
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "next dev --port ${PORT:-3000}",
|
||||
"build": "next build",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "next start",
|
||||
"lint": "biome check --write",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"name": "@repo/hooks",
|
||||
"version": "0.0.0",
|
||||
"private": true
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
anonymousClient,
|
||||
apiKeyClient,
|
||||
emailOTPClient,
|
||||
genericOAuthClient,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
usernameClient,
|
||||
|
|
@ -19,6 +20,7 @@ export const authClient = createAuthClient({
|
|||
usernameClient(),
|
||||
magicLinkClient(),
|
||||
emailOTPClient(),
|
||||
genericOAuthClient(),
|
||||
apiKeyClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => {
|
|||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
|
||||
})
|
||||
|
||||
it("does not treat an already-elapsed forgetAfter as expiring", () => {
|
||||
const past = new Date(Date.now() - 60 * 1000).toISOString()
|
||||
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const mem = makeMemory({ forgetAfter: past, createdAt: old })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
|
||||
})
|
||||
|
||||
it("returns recent color for memories created within 24 hours", () => {
|
||||
const recent = new Date(Date.now() - 1000).toISOString()
|
||||
const mem = makeMemory({ createdAt: recent })
|
||||
|
|
|
|||
|
|
@ -467,10 +467,10 @@ export function MemoryGraph({
|
|||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
graphFitHeight,
|
||||
)
|
||||
},
|
||||
[nodes, containerSize.width, containerSize.height],
|
||||
[nodes, containerSize.width, graphFitHeight],
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function getMemoryBorderColor(
|
|||
if (mem.isForgotten) return colors.memBorderForgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return colors.memBorderRecent
|
||||
|
|
|
|||
|
|
@ -229,6 +229,17 @@ openai_with_memory = with_supermemory(
|
|||
|
||||
## Manual Memory Tools
|
||||
|
||||
`SupermemoryTools` exposes seven OpenAI function-calling tools:
|
||||
|
||||
- `search_memories` and `add_memory`
|
||||
- `get_profile`
|
||||
- `document_list`, `document_add`, and `document_delete`
|
||||
- `memory_forget`
|
||||
|
||||
The configured `project_id` or `container_tags` define the trusted scope. The
|
||||
primary tag is used for profile, list, search, and forget operations, and the
|
||||
model cannot select a different tag.
|
||||
|
||||
### SupermemoryTools Class
|
||||
|
||||
```python
|
||||
|
|
@ -245,8 +256,7 @@ tools = SupermemoryTools(
|
|||
# Search memories
|
||||
result = await tools.search_memories(
|
||||
information_to_get="user preferences",
|
||||
limit=10,
|
||||
include_full_docs=True
|
||||
limit=10
|
||||
)
|
||||
|
||||
# Add memory
|
||||
|
|
@ -254,24 +264,48 @@ result = await tools.add_memory(
|
|||
memory="User prefers tea over coffee"
|
||||
)
|
||||
|
||||
# Fetch specific memory
|
||||
result = await tools.fetch_memory(
|
||||
memory_id="memory-id-here"
|
||||
# Get the configured user's profile
|
||||
result = await tools.get_profile(query="favorite drinks")
|
||||
|
||||
# List, add, or delete source documents
|
||||
documents = await tools.document_list(limit=10, page=1)
|
||||
document = await tools.document_add(
|
||||
content="Meeting notes...",
|
||||
title="Weekly meeting"
|
||||
)
|
||||
deleted = await tools.document_delete(document_id="document-id-here")
|
||||
|
||||
# Soft-forget one extracted memory
|
||||
forgotten = await tools.memory_forget(
|
||||
memory_id="memory-entry-id-here",
|
||||
reason="outdated"
|
||||
)
|
||||
```
|
||||
|
||||
`include_full_docs` is retained as a deprecated Python argument for compatibility,
|
||||
but v4 search returns relevant memories and chunks instead of full source documents.
|
||||
It is no longer exposed in the OpenAI tool schema.
|
||||
|
||||
### Individual Tools
|
||||
|
||||
```python
|
||||
from supermemory_openai import (
|
||||
create_search_memories_tool,
|
||||
create_add_memory_tool,
|
||||
create_fetch_memory_tool
|
||||
create_get_profile_tool,
|
||||
create_document_list_tool,
|
||||
create_document_delete_tool,
|
||||
create_document_add_tool,
|
||||
create_memory_forget_tool,
|
||||
)
|
||||
|
||||
search_tool = create_search_memories_tool("your-api-key")
|
||||
add_tool = create_add_memory_tool("your-api-key")
|
||||
fetch_tool = create_fetch_memory_tool("your-api-key")
|
||||
profile_tool = create_get_profile_tool("your-api-key")
|
||||
list_tool = create_document_list_tool("your-api-key")
|
||||
delete_tool = create_document_delete_tool("your-api-key")
|
||||
document_add_tool = create_document_add_tool("your-api-key")
|
||||
forget_tool = create_memory_forget_tool("your-api-key")
|
||||
```
|
||||
|
||||
### Function Calling Integration
|
||||
|
|
@ -343,6 +377,11 @@ SupermemoryTools(
|
|||
- `get_tool_definitions()` - Get OpenAI function definitions
|
||||
- `search_memories()` - Search user memories
|
||||
- `add_memory()` - Add new memory
|
||||
- `get_profile()` - Get the configured user's profile
|
||||
- `document_list()` - List source document metadata
|
||||
- `document_add()` - Queue a source document for processing
|
||||
- `document_delete()` - Delete an in-scope source document
|
||||
- `memory_forget()` - Soft-forget one extracted memory
|
||||
- `execute_tool_call()` - Execute individual tool call
|
||||
|
||||
## Error Handling
|
||||
|
|
@ -408,7 +447,7 @@ Optional for testing:
|
|||
|
||||
### Required
|
||||
- `openai>=1.102.0` - Official OpenAI Python SDK
|
||||
- `supermemory>=3.1.0` - Supermemory client
|
||||
- `supermemory>=3.50.0` - Supermemory client
|
||||
- `requests>=2.25.0` - HTTP requests (fallback)
|
||||
|
||||
### Optional
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "supermemory-openai-sdk"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
description = "Memory tools for OpenAI function calling with supermemory"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
|
@ -25,7 +25,7 @@ classifiers = [
|
|||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"openai>=1.102.0",
|
||||
"supermemory>=3.16.0",
|
||||
"supermemory>=3.50.0",
|
||||
"typing-extensions>=4.0.0",
|
||||
"requests>=2.25.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
"""Forget memory via DELETE /v4/memories (not exposed on supermemory SDK v3)."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
|
||||
async def forget_memory_request(
|
||||
api_key: str,
|
||||
container_tag: str,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
"""Mark a memory as forgotten via the v4 memories endpoint."""
|
||||
payload: dict[str, str] = {"containerTag": container_tag}
|
||||
if memory_id:
|
||||
payload["id"] = memory_id
|
||||
if memory_content:
|
||||
payload["content"] = memory_content
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.delete(
|
||||
f"{base_url}/v4/memories",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
json=payload,
|
||||
) as response:
|
||||
if not response.ok:
|
||||
error_text = await response.text()
|
||||
raise RuntimeError(
|
||||
f"Supermemory forget memory failed: {response.status} "
|
||||
f"{response.reason}. {error_text}"
|
||||
)
|
||||
except ImportError:
|
||||
import requests
|
||||
|
||||
response = requests.delete(
|
||||
f"{base_url}/v4/memories",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Supermemory forget memory failed: {response.status_code} "
|
||||
f"{response.reason}. {response.text}"
|
||||
)
|
||||
|
|
@ -222,15 +222,15 @@ async def add_memory_tool(
|
|||
) -> None:
|
||||
"""Add a new memory to the SuperMemory system."""
|
||||
try:
|
||||
add_params = {
|
||||
"content": content,
|
||||
"container_tag": container_tag,
|
||||
}
|
||||
if custom_id is not None:
|
||||
add_params["custom_id"] = custom_id
|
||||
|
||||
# Handle both sync and async supermemory clients
|
||||
result = client.add(**add_params)
|
||||
if custom_id is None:
|
||||
result = client.add(content=content, container_tag=container_tag)
|
||||
else:
|
||||
result = client.add(
|
||||
content=content,
|
||||
container_tag=container_tag,
|
||||
custom_id=custom_id,
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
response = await result
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Supermemory tools for OpenAI function calling."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
import supermemory
|
||||
from openai.types.chat import (
|
||||
|
|
@ -10,28 +11,17 @@ from openai.types.chat import (
|
|||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionToolParam,
|
||||
)
|
||||
from supermemory.types import (
|
||||
AddResponse,
|
||||
DocumentGetResponse,
|
||||
SearchMemoriesResponse,
|
||||
)
|
||||
from supermemory.types.search_memories_response import Result
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
from supermemory.types import AddResponse, SearchMemoriesResponse
|
||||
|
||||
from .exceptions import (
|
||||
SupermemoryConfigurationError,
|
||||
SupermemoryMemoryOperationError,
|
||||
SupermemoryNetworkError,
|
||||
)
|
||||
from .forget_memory import DEFAULT_BASE_URL, forget_memory_request
|
||||
from .exceptions import SupermemoryConfigurationError
|
||||
|
||||
TOOL_DESCRIPTIONS = {
|
||||
"search_memories": (
|
||||
"Search (recall) stored memories for facts, preferences, history, and context about the user "
|
||||
"or any topic. Use proactively before answering whenever memory could help — do not wait for "
|
||||
"the user to explicitly ask you to search or recall. Search when the question touches personal "
|
||||
"context, past conversations, preferences, projects, people, plans, or anything you may have "
|
||||
"learned before. Results include memory/chunk IDs — use those IDs with memory_forget to remove "
|
||||
"a specific learned fact."
|
||||
"Search stored memories and source chunks for relevant facts, preferences, "
|
||||
"history, and context. Use proactively whenever prior context could help. "
|
||||
"Hybrid results may contain either a memory or a source chunk; only an ID "
|
||||
"from a result containing a memory can be passed to memory_forget."
|
||||
),
|
||||
"add_memory": (
|
||||
"Add (remember) memories/details/information about the user or other facts or entities. "
|
||||
|
|
@ -40,19 +30,19 @@ TOOL_DESCRIPTIONS = {
|
|||
),
|
||||
"get_profile": (
|
||||
"Get user profile containing static memories (permanent facts) and dynamic memories "
|
||||
"(recent context). Optionally include search results by providing a query. "
|
||||
"Profile and search result entries may include memory IDs useful for memory_forget."
|
||||
"(recent context). Optionally include query-relevant search results. Static and dynamic "
|
||||
"profile entries are text; only memory entries in search results have forgettable IDs."
|
||||
),
|
||||
"document_list": (
|
||||
"List stored source documents (conversations, URLs, files, pasted text) with pagination. "
|
||||
"Returns document IDs for document_delete — not memory IDs for memory_forget. "
|
||||
"Use to browse raw stored content before permanently removing a source."
|
||||
"Returns document metadata and summaries, including IDs for document_delete. "
|
||||
"It does not return full source content or memory IDs."
|
||||
),
|
||||
"document_delete": (
|
||||
"Permanently delete a stored document and ALL memories extracted from it (hard delete). "
|
||||
"Use document IDs from document_list. Use when the user wants to remove an entire "
|
||||
"conversation, file, URL, or other source — not when correcting a single learned fact "
|
||||
"(use memory_forget for that)."
|
||||
"Permanently delete a stored source document and soft-forget memories extracted from it. "
|
||||
"Use a document ID from document_list when the user wants to remove an entire source. "
|
||||
"Deletion is refused for documents outside the configured scope, shared with another "
|
||||
"scope, or still processing. Use memory_forget to remove one learned fact."
|
||||
),
|
||||
"document_add": (
|
||||
"Store a source document for asynchronous processing and automatic memory extraction. "
|
||||
|
|
@ -67,10 +57,9 @@ TOOL_DESCRIPTIONS = {
|
|||
),
|
||||
"memory_forget": (
|
||||
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in "
|
||||
"profile or search. Does NOT delete source documents. Provide memory_id (preferred — from "
|
||||
"search_memories or get_profile) OR memory_content for an exact text match. Use when the "
|
||||
"user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). "
|
||||
"To remove an entire conversation or file, use document_delete instead."
|
||||
"profile or search. This does not delete source documents. Provide a memory_id from a "
|
||||
"search result containing a memory, or memory_content for an exact text match. Use "
|
||||
"document_delete to remove an entire source."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -79,19 +68,14 @@ PARAMETER_DESCRIPTIONS = {
|
|||
"What to look up in memory — keywords from the user's message, topic, entity names, or "
|
||||
"question phrasing. Search even when the user did not explicitly ask you to recall."
|
||||
),
|
||||
"include_full_docs": (
|
||||
"Whether to include the full document content in the response. "
|
||||
"Defaults to true for better AI context."
|
||||
),
|
||||
"limit": "Maximum number of results to return",
|
||||
"memory": (
|
||||
"The text content of the memory to add. This should be a single sentence or a short paragraph."
|
||||
),
|
||||
"container_tag": "Tag to filter/scope the operation (e.g., user ID, project ID)",
|
||||
"query": "Optional search query to include relevant search results",
|
||||
"page": "Page number to fetch, 1-based (default: 1)",
|
||||
"document_id": (
|
||||
"Document ID from document_list — permanently deletes the source document and all "
|
||||
"Document ID from document_list. Permanently deletes the source and soft-forgets its "
|
||||
"extracted memories. Not a profile memory ID."
|
||||
),
|
||||
"content": (
|
||||
|
|
@ -102,8 +86,8 @@ PARAMETER_DESCRIPTIONS = {
|
|||
"title": "Optional title for the document",
|
||||
"description": "Optional description for the document",
|
||||
"memory_id": (
|
||||
"Profile memory ID from search_memories or get_profile — soft-deletes one learned fact. "
|
||||
"Not a document ID."
|
||||
"Memory entry ID from a search_memories result containing a memory. Chunk and document "
|
||||
"IDs are invalid."
|
||||
),
|
||||
"memory_content": (
|
||||
"Exact text of the profile memory to forget (alternative to memory_id). Must match "
|
||||
|
|
@ -114,7 +98,6 @@ PARAMETER_DESCRIPTIONS = {
|
|||
|
||||
DEFAULT_LIMIT = 10
|
||||
DEFAULT_CHUNK_THRESHOLD = 0.6
|
||||
DEFAULT_INCLUDE_FULL_DOCS = True
|
||||
|
||||
ALL_TOOL_NAMES = (
|
||||
"search_memories",
|
||||
|
|
@ -131,6 +114,8 @@ class SupermemoryToolsConfig(TypedDict, total=False):
|
|||
"""Configuration for Supermemory tools.
|
||||
|
||||
Only one of `project_id` or `container_tags` can be provided.
|
||||
The first container tag is used for single-space operations. All configured
|
||||
tags are applied to additions and define the allowed document-delete scope.
|
||||
"""
|
||||
|
||||
base_url: Optional[str]
|
||||
|
|
@ -138,15 +123,15 @@ class SupermemoryToolsConfig(TypedDict, total=False):
|
|||
project_id: Optional[str]
|
||||
|
||||
|
||||
# Type aliases using inferred types from supermemory package
|
||||
MemoryObject = Union[DocumentGetResponse, AddResponse]
|
||||
# Type alias retained for compatibility with earlier releases.
|
||||
MemoryObject = AddResponse
|
||||
|
||||
|
||||
class MemorySearchResult(TypedDict, total=False):
|
||||
"""Result type for memory search operations."""
|
||||
|
||||
success: bool
|
||||
results: Optional[List[Result]]
|
||||
results: Optional[List[Dict[str, object]]]
|
||||
count: Optional[int]
|
||||
error: Optional[str]
|
||||
|
||||
|
|
@ -163,8 +148,8 @@ class ProfileResult(TypedDict, total=False):
|
|||
"""Result type for profile operations."""
|
||||
|
||||
success: bool
|
||||
profile: Optional[Dict[str, Any]]
|
||||
search_results: Optional[Any]
|
||||
profile: Optional[Dict[str, object]]
|
||||
search_results: Optional[Dict[str, object]]
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
|
|
@ -172,8 +157,8 @@ class DocumentListResult(TypedDict, total=False):
|
|||
"""Result type for document list operations."""
|
||||
|
||||
success: bool
|
||||
documents: Optional[List[Any]]
|
||||
pagination: Optional[Any]
|
||||
documents: Optional[List[Dict[str, object]]]
|
||||
pagination: Optional[Dict[str, object]]
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
|
|
@ -202,7 +187,7 @@ class MemoryForgetResult(TypedDict, total=False):
|
|||
|
||||
|
||||
# Function schemas for OpenAI function calling
|
||||
MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
||||
MEMORY_TOOL_SCHEMAS: Dict[str, FunctionDefinition] = {
|
||||
"search_memories": {
|
||||
"name": "search_memories",
|
||||
"description": TOOL_DESCRIPTIONS["search_memories"],
|
||||
|
|
@ -213,18 +198,16 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["information_to_get"],
|
||||
},
|
||||
"include_full_docs": {
|
||||
"type": "boolean",
|
||||
"description": PARAMETER_DESCRIPTIONS["include_full_docs"],
|
||||
"default": DEFAULT_INCLUDE_FULL_DOCS,
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["limit"],
|
||||
"default": DEFAULT_LIMIT,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["information_to_get"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"add_memory": {
|
||||
|
|
@ -239,6 +222,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["memory"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"get_profile": {
|
||||
|
|
@ -247,16 +231,13 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["query"],
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_list": {
|
||||
|
|
@ -265,21 +246,22 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["limit"],
|
||||
"default": DEFAULT_LIMIT,
|
||||
"minimum": 1,
|
||||
"maximum": 1100,
|
||||
},
|
||||
"page": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"description": PARAMETER_DESCRIPTIONS["page"],
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_delete": {
|
||||
|
|
@ -294,6 +276,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["document_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"document_add": {
|
||||
|
|
@ -316,6 +299,7 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"memory_forget": {
|
||||
|
|
@ -324,10 +308,6 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"container_tag": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["container_tag"],
|
||||
},
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": PARAMETER_DESCRIPTIONS["memory_id"],
|
||||
|
|
@ -342,20 +322,28 @@ MEMORY_TOOL_SCHEMAS: Dict[str, ChatCompletionFunctionToolParam] = {
|
|||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_container_tags(config: SupermemoryToolsConfig) -> List[str]:
|
||||
if config.get("project_id") is not None and config.get("container_tags") is not None:
|
||||
project_id = config.get("project_id")
|
||||
configured_tags = config.get("container_tags")
|
||||
|
||||
if project_id is not None and configured_tags is not None:
|
||||
raise SupermemoryConfigurationError(
|
||||
"Supermemory tools config accepts either project_id or container_tags, not both."
|
||||
)
|
||||
if config.get("project_id"):
|
||||
return [f"sm_project_{config['project_id']}"]
|
||||
if config.get("container_tags"):
|
||||
return config["container_tags"]
|
||||
if project_id:
|
||||
return [f"sm_project_{project_id}"]
|
||||
if configured_tags is not None:
|
||||
if not configured_tags or any(not tag for tag in configured_tags):
|
||||
raise SupermemoryConfigurationError(
|
||||
"container_tags must contain at least one non-empty tag."
|
||||
)
|
||||
return list(configured_tags)
|
||||
return ["sm_project_default"]
|
||||
|
||||
|
||||
|
|
@ -363,8 +351,25 @@ def _tool_definition(name: str) -> ChatCompletionToolParam:
|
|||
return {"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
|
||||
|
||||
|
||||
def _model_to_dict(value: Any) -> Dict[str, object]:
|
||||
"""Normalize generated SDK models and already-plain response values."""
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
dumped = model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
return dumped
|
||||
|
||||
raise TypeError(f"Unsupported SDK response type: {type(value).__name__}")
|
||||
|
||||
|
||||
def _all_tool_definitions() -> List[ChatCompletionFunctionToolParam]:
|
||||
return [{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]} for name in ALL_TOOL_NAMES]
|
||||
return [
|
||||
{"type": "function", "function": MEMORY_TOOL_SCHEMAS[name]}
|
||||
for name in ALL_TOOL_NAMES
|
||||
]
|
||||
|
||||
|
||||
class SupermemoryTools:
|
||||
|
|
@ -378,18 +383,18 @@ class SupermemoryTools:
|
|||
config: Optional configuration
|
||||
"""
|
||||
config = config or {}
|
||||
self.api_key = api_key
|
||||
self.base_url = config.get("base_url") or DEFAULT_BASE_URL
|
||||
|
||||
client_kwargs = {"api_key": api_key}
|
||||
if config.get("base_url"):
|
||||
client_kwargs["base_url"] = config["base_url"]
|
||||
|
||||
self.client = supermemory.AsyncSupermemory(**client_kwargs)
|
||||
base_url = config.get("base_url")
|
||||
if base_url:
|
||||
self.client = supermemory.AsyncSupermemory(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
else:
|
||||
self.client = supermemory.AsyncSupermemory(api_key=api_key)
|
||||
self.container_tags = _resolve_container_tags(config)
|
||||
|
||||
def _primary_container_tag(self, container_tag: Optional[str] = None) -> str:
|
||||
return container_tag or self.container_tags[0]
|
||||
def _primary_container_tag(self) -> str:
|
||||
return self.container_tags[0]
|
||||
|
||||
def get_tool_definitions(self) -> List[ChatCompletionFunctionToolParam]:
|
||||
"""Get OpenAI function definitions for all memory tools."""
|
||||
|
|
@ -398,9 +403,7 @@ class SupermemoryTools:
|
|||
async def execute_tool_call(self, tool_call: ChatCompletionMessageToolCall) -> str:
|
||||
"""Execute a tool call based on the function name and arguments."""
|
||||
function_name = tool_call.function.name
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
|
||||
handlers = {
|
||||
handlers: Dict[str, Any] = {
|
||||
"search_memories": self.search_memories,
|
||||
"add_memory": self.add_memory,
|
||||
"get_profile": self.get_profile,
|
||||
|
|
@ -417,6 +420,24 @@ class SupermemoryTools:
|
|||
"error": f"Unknown function: {function_name}",
|
||||
}
|
||||
else:
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
if not isinstance(args, dict):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
parameters = MEMORY_TOOL_SCHEMAS[function_name]["parameters"] or {}
|
||||
properties = parameters.get("properties", {})
|
||||
required = parameters.get("required", [])
|
||||
if not isinstance(properties, dict) or not isinstance(required, list):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
required_names = {name for name in required if isinstance(name, str)}
|
||||
if set(args) - set(properties) or required_names - set(args):
|
||||
return json.dumps({"success": False, "error": "Invalid tool arguments"})
|
||||
|
||||
result = await handler(**args)
|
||||
|
||||
return json.dumps(result)
|
||||
|
|
@ -424,14 +445,26 @@ class SupermemoryTools:
|
|||
async def search_memories(
|
||||
self,
|
||||
information_to_get: str,
|
||||
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
|
||||
include_full_docs: Optional[bool] = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> MemorySearchResult:
|
||||
"""Search memories."""
|
||||
"""Search memories.
|
||||
|
||||
``include_full_docs`` remains a deprecated Python-only argument for
|
||||
source compatibility. V4 search cannot return full source documents.
|
||||
"""
|
||||
if include_full_docs is not None:
|
||||
warnings.warn(
|
||||
"include_full_docs is deprecated and ignored because v4 search "
|
||||
"does not return full source documents",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
try:
|
||||
response: SearchMemoriesResponse = await self.client.search.memories(
|
||||
q=information_to_get,
|
||||
container_tags=self.container_tags,
|
||||
container_tag=self._primary_container_tag(),
|
||||
limit=limit,
|
||||
threshold=DEFAULT_CHUNK_THRESHOLD,
|
||||
search_mode="hybrid",
|
||||
|
|
@ -479,27 +512,28 @@ class SupermemoryTools:
|
|||
|
||||
async def get_profile(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> ProfileResult:
|
||||
"""Get user profile with optional query-scoped search results."""
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tag": self._primary_container_tag(container_tag),
|
||||
}
|
||||
if query:
|
||||
kwargs["q"] = query
|
||||
|
||||
response = await self.client.profile(**kwargs)
|
||||
profile = response.profile if hasattr(response, "profile") else None
|
||||
search_results = (
|
||||
response.search_results if hasattr(response, "search_results") else None
|
||||
)
|
||||
response = await self.client.profile(
|
||||
container_tag=self._primary_container_tag(),
|
||||
q=query,
|
||||
)
|
||||
else:
|
||||
response = await self.client.profile(
|
||||
container_tag=self._primary_container_tag(),
|
||||
)
|
||||
|
||||
return ProfileResult(
|
||||
success=True,
|
||||
profile=profile if isinstance(profile, dict) else None,
|
||||
search_results=search_results,
|
||||
profile=_model_to_dict(response.profile),
|
||||
search_results=(
|
||||
_model_to_dict(response.search_results)
|
||||
if response.search_results is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except (OSError, ConnectionError) as network_error:
|
||||
return ProfileResult(
|
||||
|
|
@ -514,27 +548,24 @@ class SupermemoryTools:
|
|||
|
||||
async def document_list(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
page: Optional[int] = None,
|
||||
) -> DocumentListResult:
|
||||
"""List stored documents."""
|
||||
try:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tags": [self._primary_container_tag(container_tag)],
|
||||
"limit": limit or DEFAULT_LIMIT,
|
||||
"container_tags": [self._primary_container_tag()],
|
||||
"limit": DEFAULT_LIMIT if limit is None else limit,
|
||||
}
|
||||
if page is not None:
|
||||
kwargs["page"] = page
|
||||
|
||||
response = await self.client.documents.list(**kwargs)
|
||||
documents = response.memories if hasattr(response, "memories") else []
|
||||
pagination = response.pagination if hasattr(response, "pagination") else None
|
||||
|
||||
return DocumentListResult(
|
||||
success=True,
|
||||
documents=documents,
|
||||
pagination=pagination,
|
||||
documents=[_model_to_dict(document) for document in response.memories],
|
||||
pagination=_model_to_dict(response.pagination),
|
||||
)
|
||||
except (OSError, ConnectionError) as network_error:
|
||||
return DocumentListResult(
|
||||
|
|
@ -550,7 +581,19 @@ class SupermemoryTools:
|
|||
async def document_delete(self, document_id: str) -> DocumentDeleteResult:
|
||||
"""Delete a document by ID."""
|
||||
try:
|
||||
await self.client.documents.delete(document_id)
|
||||
# The delete endpoint has no container-tag argument. Resolve custom IDs
|
||||
# first and refuse documents whose complete tag set is not configured.
|
||||
document = await self.client.documents.get(document_id)
|
||||
document_tags = set(document.container_tags or [])
|
||||
configured_tags = set(self.container_tags)
|
||||
|
||||
if not document_tags or not document_tags.issubset(configured_tags):
|
||||
return DocumentDeleteResult(
|
||||
success=False,
|
||||
error="Document is outside configured scope",
|
||||
)
|
||||
|
||||
await self.client.documents.delete(document.id)
|
||||
return DocumentDeleteResult(
|
||||
success=True,
|
||||
message=f"Document {document_id} deleted successfully",
|
||||
|
|
@ -605,7 +648,6 @@ class SupermemoryTools:
|
|||
|
||||
async def memory_forget(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
|
|
@ -618,14 +660,17 @@ class SupermemoryTools:
|
|||
)
|
||||
|
||||
try:
|
||||
await forget_memory_request(
|
||||
api_key=self.api_key,
|
||||
container_tag=self._primary_container_tag(container_tag),
|
||||
memory_id=memory_id,
|
||||
memory_content=memory_content,
|
||||
reason=reason,
|
||||
base_url=self.base_url,
|
||||
)
|
||||
kwargs: Dict[str, Any] = {
|
||||
"container_tag": self._primary_container_tag(),
|
||||
}
|
||||
if memory_id:
|
||||
kwargs["id"] = memory_id
|
||||
if memory_content:
|
||||
kwargs["content"] = memory_content
|
||||
if reason:
|
||||
kwargs["reason"] = reason
|
||||
|
||||
await self.client.memories.forget(**kwargs)
|
||||
return MemoryForgetResult(
|
||||
success=True,
|
||||
message="Memory forgotten successfully",
|
||||
|
|
@ -691,7 +736,7 @@ class SearchMemoriesTool:
|
|||
async def execute(
|
||||
self,
|
||||
information_to_get: str,
|
||||
include_full_docs: bool = DEFAULT_INCLUDE_FULL_DOCS,
|
||||
include_full_docs: Optional[bool] = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
) -> MemorySearchResult:
|
||||
"""Execute search memories."""
|
||||
|
|
@ -723,11 +768,10 @@ class GetProfileTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
) -> ProfileResult:
|
||||
"""Execute get profile."""
|
||||
return await self.tools.get_profile(container_tag=container_tag, query=query)
|
||||
return await self.tools.get_profile(query=query)
|
||||
|
||||
|
||||
class DocumentListTool:
|
||||
|
|
@ -739,13 +783,11 @@ class DocumentListTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
page: Optional[int] = None,
|
||||
) -> DocumentListResult:
|
||||
"""Execute document list."""
|
||||
return await self.tools.document_list(
|
||||
container_tag=container_tag,
|
||||
limit=limit,
|
||||
page=page,
|
||||
)
|
||||
|
|
@ -793,14 +835,12 @@ class MemoryForgetTool:
|
|||
|
||||
async def execute(
|
||||
self,
|
||||
container_tag: Optional[str] = None,
|
||||
memory_id: Optional[str] = None,
|
||||
memory_content: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> MemoryForgetResult:
|
||||
"""Execute memory forget."""
|
||||
return await self.tools.memory_forget(
|
||||
container_tag=container_tag,
|
||||
memory_id=memory_id,
|
||||
memory_content=memory_content,
|
||||
reason=reason,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ class TestToolDefinitions:
|
|||
assert search_tool is not None
|
||||
assert search_tool["type"] == "function"
|
||||
assert "information_to_get" in search_tool["function"]["parameters"]["required"]
|
||||
assert (
|
||||
"include_full_docs"
|
||||
not in search_tool["function"]["parameters"]["properties"]
|
||||
)
|
||||
|
||||
# Check addMemory
|
||||
add_tool = next(
|
||||
|
|
@ -200,25 +204,32 @@ class TestMemoryOperationsUnit:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories_uses_search_memories_hybrid(self):
|
||||
"""search_memories must call client.search.memories with hybrid mode."""
|
||||
"""V4 search must use the primary singular tag and hybrid mode."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
tools = SupermemoryTools("test-key", {"container_tags": ["unit-tag"]})
|
||||
tools = SupermemoryTools(
|
||||
"test-key", {"container_tags": ["primary-tag", "secondary-tag"]}
|
||||
)
|
||||
tools.client.search.memories = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
results=[SimpleNamespace(model_dump=lambda: {"memory": "likes tea"})]
|
||||
)
|
||||
)
|
||||
|
||||
result = await tools.search_memories("tea", limit=3)
|
||||
with pytest.warns(DeprecationWarning, match="include_full_docs"):
|
||||
result = await tools.search_memories(
|
||||
"tea", include_full_docs=False, limit=3
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
tools.client.search.memories.assert_awaited_once()
|
||||
kwargs = tools.client.search.memories.await_args.kwargs
|
||||
assert kwargs["q"] == "tea"
|
||||
assert kwargs["container_tags"] == ["unit-tag"]
|
||||
assert kwargs["container_tag"] == "primary-tag"
|
||||
assert "container_tags" not in kwargs
|
||||
assert "include_full_docs" not in kwargs
|
||||
assert kwargs["limit"] == 3
|
||||
assert kwargs["search_mode"] == "hybrid"
|
||||
|
||||
|
|
|
|||
16
packages/openai-sdk-python/uv.lock
generated
16
packages/openai-sdk-python/uv.lock
generated
|
|
@ -377,7 +377,7 @@ resolution-markers = [
|
|||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
wheels = [
|
||||
|
|
@ -392,7 +392,7 @@ resolution-markers = [
|
|||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
|
|
@ -422,7 +422,7 @@ name = "exceptiongroup"
|
|||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
|
|
@ -1355,7 +1355,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "supermemory"
|
||||
version = "3.56.0"
|
||||
version = "3.59.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1365,14 +1365,14 @@ dependencies = [
|
|||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/43/3a7619a697554555d37254bd1e63ff2bf1262bae8ab569eba57f823af3e4/supermemory-3.56.0.tar.gz", hash = "sha256:3cceb35465e79762c2213a56d2d17b38c554924ba19fd29e24ce09701f4b377d", size = 175386, upload-time = "2026-07-24T16:29:16.695Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/83/7db28873de639f4c4ac469266a2142d1f9def545a0cac2a023011df23601/supermemory-3.59.0.tar.gz", hash = "sha256:5efd5a5a087d552b0e00e739e4eeac21379d087bdfaa1c9f5995d11272b35bb3", size = 154326, upload-time = "2026-08-14T21:28:39.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/2a/0d8ac89c73f540caa38ced4df3b824abae11481243c60f6ef750103c973d/supermemory-3.56.0-py3-none-any.whl", hash = "sha256:334e5cd1a8743ed9b90aa25ad0e63266f6376b4d44d5bdfc5cc4f15fc19d72f9", size = 156297, upload-time = "2026-07-24T16:29:15.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/73/0cf59b31317baf66b0bb9d1fa6506a54013e49fb35ffc9c12c3f3a189c5c/supermemory-3.59.0-py3-none-any.whl", hash = "sha256:3c66ae0fcb082241d8a600b4f8aaebe68159aee7cd24c2ce1aae533ea404bb2d", size = 142020, upload-time = "2026-08-14T21:28:38.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supermemory-openai-sdk"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openai" },
|
||||
|
|
@ -1402,7 +1402,7 @@ requires-dist = [
|
|||
{ name = "aiohttp", marker = "extra == 'async'", specifier = ">=3.8.0" },
|
||||
{ name = "openai", specifier = ">=1.102.0" },
|
||||
{ name = "requests", specifier = ">=2.25.0" },
|
||||
{ name = "supermemory", specifier = ">=3.16.0" },
|
||||
{ name = "supermemory", specifier = ">=3.50.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.0.0" },
|
||||
]
|
||||
provides-extras = ["async"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export interface ForgetMemoryParams {
|
||||
containerTag: string
|
||||
|
|
@ -7,6 +8,10 @@ export interface ForgetMemoryParams {
|
|||
reason?: string
|
||||
}
|
||||
|
||||
export interface ForgetMemoryRequestOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a memory as forgotten via `DELETE /v4/memories`.
|
||||
*
|
||||
|
|
@ -19,6 +24,7 @@ export async function forgetMemoryRequest(
|
|||
apiKey: string,
|
||||
params: ForgetMemoryParams,
|
||||
baseUrl: string = DEFAULT_BASE_URL,
|
||||
options?: ForgetMemoryRequestOptions,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/v4/memories`, {
|
||||
method: "DELETE",
|
||||
|
|
@ -27,6 +33,7 @@ export async function forgetMemoryRequest(
|
|||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,22 @@ describe("memoryForget", () => {
|
|||
id: "mem_1",
|
||||
reason: "outdated",
|
||||
})
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it("uses a caller-provided signal instead of creating a timeout", async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const controller = new AbortController()
|
||||
|
||||
await forgetMemoryRequest(
|
||||
API_KEY,
|
||||
{ containerTag: "user_1", id: "mem_1" },
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(init.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it("throws a descriptive error on non-2xx responses", async () => {
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export const AnonymousAuth = ({
|
||||
dashboardPath = "/dashboard",
|
||||
loginPath = "/login",
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const createAnonymousSession = async () => {
|
||||
const session = await authClient.getSession()
|
||||
|
||||
if (!session?.session) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No session found, creating anonymous session...",
|
||||
)
|
||||
|
||||
try {
|
||||
// Create anonymous session
|
||||
console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
|
||||
const res = await authClient.signIn.anonymous()
|
||||
|
||||
if (!res.token) {
|
||||
throw new Error("Failed to get anonymous token")
|
||||
}
|
||||
|
||||
// Get the new session
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Getting new session with anonymous token...",
|
||||
)
|
||||
const newSession = await authClient.getSession()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
|
||||
|
||||
if (!newSession?.session || !newSession?.user) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
|
||||
)
|
||||
throw new Error("Failed to create anonymous session")
|
||||
}
|
||||
|
||||
// Get the user's organization
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
const org = orgs?.[0]
|
||||
if (!org) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] No organization found for anonymous user",
|
||||
)
|
||||
throw new Error("Failed to get organization for anonymous user")
|
||||
}
|
||||
|
||||
// Redirect to the organization dashboard
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Anonymous session creation error:",
|
||||
error,
|
||||
)
|
||||
console.error("[ANONYMOUS_AUTH] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
router.push(loginPath)
|
||||
}
|
||||
} else if (session.session) {
|
||||
// Session exists, handle organization routing
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Session exists, checking organization...",
|
||||
)
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
if (orgs?.[0]) {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
|
||||
)
|
||||
await authClient.organization.setActive({
|
||||
organizationId: orgs[0].id,
|
||||
})
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
|
||||
)
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching full organization details...",
|
||||
)
|
||||
const org = await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
},
|
||||
})
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
})
|
||||
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAnonymousSession()
|
||||
}, [router.push])
|
||||
|
||||
// Return null as this component only handles the redirect logic
|
||||
return null
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
|
||||
import {
|
||||
DocumentsWithMemoriesQuerySchema,
|
||||
ListMemoriesQuerySchema,
|
||||
SearchRequestSchema,
|
||||
Searchv4RequestSchema,
|
||||
} from "./api"
|
||||
|
||||
describe("search threshold schemas", () => {
|
||||
it("do not contain redundant number transforms or unreachable range guards", () => {
|
||||
|
|
@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination query schemas", () => {
|
||||
it("preserve page/limit defaults", () => {
|
||||
const listed = ListMemoriesQuerySchema.parse({})
|
||||
expect(listed.page).toBe(1)
|
||||
expect(listed.limit).toBe(10)
|
||||
|
||||
const docs = DocumentsWithMemoriesQuerySchema.parse({})
|
||||
expect(docs.page).toBe(1)
|
||||
expect(docs.limit).toBe(10)
|
||||
})
|
||||
|
||||
it.each([
|
||||
1, 50, 1100,
|
||||
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
|
||||
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
|
||||
expect(parsed.page).toBe(3)
|
||||
expect(parsed.limit).toBe(25)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -5, 2.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 1.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema still caps limit at 1100", () => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -10, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
|
||||
const parsed = DocumentsWithMemoriesQuerySchema.parse({
|
||||
page: 2,
|
||||
limit: 50,
|
||||
})
|
||||
expect(parsed.page).toBe(2)
|
||||
expect(parsed.limit).toBe(50)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Limit must be a positive integer",
|
||||
})
|
||||
.refine((value) => value <= 1100, {
|
||||
message: "Limit cannot be greater than 1100",
|
||||
})
|
||||
|
|
@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Page must be a positive integer",
|
||||
})
|
||||
.default("1")
|
||||
.openapi({ description: "Page number to fetch", example: "1" }),
|
||||
sort: z
|
||||
|
|
@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z
|
|||
|
||||
export const DocumentsWithMemoriesQuerySchema = z
|
||||
.object({
|
||||
page: z.number().default(1).openapi({
|
||||
page: z.number().int().min(1).default(1).openapi({
|
||||
description: "Page number to fetch",
|
||||
example: 1,
|
||||
}),
|
||||
limit: z.number().default(10).openapi({
|
||||
limit: z.number().int().min(1).default(10).openapi({
|
||||
description: "Number of items per page",
|
||||
example: 10,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,5 +2,8 @@
|
|||
"name": "@repo/validation",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue