mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(python-sdks): harden v4 integrations
This commit is contained in:
commit
903d78e2ed
131 changed files with 5763 additions and 2343 deletions
226
.github/workflows/ci-python.yml
vendored
226
.github/workflows/ci-python.yml
vendored
|
|
@ -9,97 +9,249 @@ 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 (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
dependency-lane: minimum-supermemory
|
||||
supermemory-version: "3.16.0"
|
||||
- python-version: "3.13"
|
||||
dependency-lane: current-supermemory
|
||||
supermemory-version: "3.59.0"
|
||||
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 tested Supermemory SDK
|
||||
run: >-
|
||||
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||
"supermemory==${{ matrix.supermemory-version }}"
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: python -m pip check
|
||||
|
||||
- name: Verify installed wheel and SDK version
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version; from pathlib import Path;
|
||||
import supermemory_agent_framework;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
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-dependencies
|
||||
supermemory-version: "3.16.0"
|
||||
cartesia-line-version: "0.2.0"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-dependencies
|
||||
supermemory-version: "3.59.0"
|
||||
cartesia-line-version: "0.2.17"
|
||||
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 and test tools
|
||||
run: python -m pip install build pytest
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel and tested runtime dependencies
|
||||
run: >-
|
||||
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||
"supermemory==${{ matrix.supermemory-version }}"
|
||||
"cartesia-line==${{ matrix.cartesia-line-version }}"
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: python -m pip check
|
||||
|
||||
- name: Verify real Cartesia Line integration and run tests
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import line, pytest, supermemory_cartesia;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
assert version('cartesia-line') == '${{ matrix.cartesia-line-version }}';
|
||||
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
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-dependencies
|
||||
supermemory-version: "3.16.0"
|
||||
pipecat-version: "0.0.98"
|
||||
- python-version: "3.12"
|
||||
dependency-lane: current-dependencies
|
||||
supermemory-version: "3.59.0"
|
||||
pipecat-version: "1.7.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 and test tools
|
||||
run: python -m pip install build pytest
|
||||
|
||||
- name: Build wheel
|
||||
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||
|
||||
- name: Install wheel and tested runtime dependencies
|
||||
run: >-
|
||||
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||
"supermemory==${{ matrix.supermemory-version }}"
|
||||
"pipecat-ai==${{ matrix.pipecat-version }}"
|
||||
|
||||
- name: Check dependency compatibility
|
||||
run: python -m pip check
|
||||
|
||||
- name: Verify real Pipecat integration and run tests
|
||||
run: >-
|
||||
python -c "from importlib.metadata import version;
|
||||
from pathlib import Path; import pipecat, pytest, supermemory_pipecat;
|
||||
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||
assert version('pipecat-ai') == '${{ matrix.pipecat-version }}';
|
||||
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
|
||||
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||
raise SystemExit(result)"
|
||||
|
|
|
|||
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
|
|
@ -26,8 +26,43 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
||||
- name: Detect SDK package changes
|
||||
id: sdk-changes
|
||||
run: |
|
||||
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
|
||||
echo "tools=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tools=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/ai-sdk; then
|
||||
echo "ai_sdk=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ai_sdk=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Run Tools unit tests
|
||||
if: steps.sdk-changes.outputs.tools == 'true'
|
||||
run: bun run --cwd packages/tools test:unit
|
||||
|
||||
- name: Build Tools package
|
||||
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true'
|
||||
run: bun run --cwd packages/tools build
|
||||
|
||||
- name: Run AI SDK type checking
|
||||
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true'
|
||||
run: bun run --cwd packages/ai-sdk check-types
|
||||
|
||||
- name: Run AI SDK unit tests
|
||||
if: steps.sdk-changes.outputs.ai_sdk == 'true'
|
||||
run: bun run --cwd packages/ai-sdk test:unit
|
||||
|
||||
- name: Build AI SDK package
|
||||
if: steps.sdk-changes.outputs.ai_sdk == 'true'
|
||||
run: bun run --cwd packages/ai-sdk build
|
||||
|
||||
- name: Run Memory Graph type checking
|
||||
run: bun run --cwd packages/memory-graph check-types
|
||||
|
||||
- name: Run Biome CI (format & lint on changed files)
|
||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||
|
|
|
|||
|
|
@ -23,20 +23,22 @@ jobs:
|
|||
working-directory: ./packages/agent-framework-python
|
||||
steps:
|
||||
- name: Checkout
|
||||
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"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: pip install hatchling build
|
||||
run: python -m pip install hatchling build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: packages/agent-framework-python/dist/
|
||||
|
|
|
|||
53
.github/workflows/publish-ai-sdk.yml
vendored
53
.github/workflows/publish-ai-sdk.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
paths:
|
||||
- "packages/ai-sdk/package.json"
|
||||
|
||||
concurrency:
|
||||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
defaults:
|
||||
|
|
@ -38,26 +38,65 @@ jobs:
|
|||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
working-directory: .
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Check if version changed
|
||||
id: version-check
|
||||
run: |
|
||||
PACKAGE_NAME=$(jq -r '.name' package.json)
|
||||
LOCAL_VERSION=$(jq -r '.version' package.json)
|
||||
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
|
||||
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
|
||||
if npm view "$PACKAGE_NAME@$LOCAL_VERSION" version >/dev/null 2>&1; then
|
||||
echo "Version $LOCAL_VERSION already published, skipping."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
|
||||
echo "Publishing $LOCAL_VERSION."
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
- name: Wait for the Tools dependency
|
||||
if: steps.version-check.outputs.changed == 'true'
|
||||
run: |
|
||||
TOOLS_SPEC=$(jq -r '.dependencies["@supermemory/tools"]' package.json)
|
||||
TOOLS_VERSION=${TOOLS_SPEC#^}
|
||||
TOOLS_VERSION=${TOOLS_VERSION#~}
|
||||
|
||||
if [[ ! "$TOOLS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Unsupported @supermemory/tools dependency spec: $TOOLS_SPEC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in {1..20}; do
|
||||
PUBLISHED_VERSION=$(npm view "@supermemory/tools@$TOOLS_VERSION" version 2>/dev/null || true)
|
||||
if [ "$PUBLISHED_VERSION" = "$TOOLS_VERSION" ]; then
|
||||
echo "@supermemory/tools@$TOOLS_VERSION is available on npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for @supermemory/tools@$TOOLS_VERSION (attempt $attempt/20)."
|
||||
sleep 15
|
||||
done
|
||||
|
||||
echo "@supermemory/tools@$TOOLS_VERSION was not published within five minutes." >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Tools dependency
|
||||
if: steps.version-check.outputs.changed == 'true'
|
||||
run: bun run --cwd ../tools build
|
||||
|
||||
- name: Build AI SDK package
|
||||
if: steps.version-check.outputs.changed == 'true'
|
||||
run: bun run build
|
||||
|
||||
- name: Verify packed artifact
|
||||
if: steps.version-check.outputs.changed == 'true'
|
||||
run: |
|
||||
npm pack --dry-run --json > "$RUNNER_TEMP/ai-sdk-pack.json"
|
||||
jq -e '
|
||||
(.[0].files | any(.path == "dist/index.js")) and
|
||||
(.[0].files | any(.path == "dist/index.d.ts"))
|
||||
' "$RUNNER_TEMP/ai-sdk-pack.json" >/dev/null
|
||||
|
||||
- name: Publish
|
||||
if: steps.version-check.outputs.changed == 'true'
|
||||
run: npm publish --access public --provenance
|
||||
|
|
|
|||
|
|
@ -23,20 +23,22 @@ jobs:
|
|||
working-directory: ./packages/cartesia-sdk-python
|
||||
steps:
|
||||
- name: Checkout
|
||||
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"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: pip install hatchling build
|
||||
run: python -m pip install hatchling build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: packages/cartesia-sdk-python/dist/
|
||||
|
|
|
|||
10
.github/workflows/publish-pipecat-sdk-python.yml
vendored
10
.github/workflows/publish-pipecat-sdk-python.yml
vendored
|
|
@ -23,20 +23,22 @@ jobs:
|
|||
working-directory: ./packages/pipecat-sdk-python
|
||||
steps:
|
||||
- name: Checkout
|
||||
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"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: pip install hatchling build
|
||||
run: python -m pip install hatchling build
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
packages-dir: packages/pipecat-sdk-python/dist/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
bun.lock
4
bun.lock
|
|
@ -258,7 +258,7 @@
|
|||
},
|
||||
"packages/ai-sdk": {
|
||||
"name": "@supermemory/ai-sdk",
|
||||
"version": "1.0.9",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.22",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
|
|
@ -337,7 +337,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
|
|||
|
|
@ -9,23 +9,13 @@ This package provides both **automatic memory injection middleware** and **manua
|
|||
Install using uv (recommended):
|
||||
|
||||
```bash
|
||||
uv add --prerelease=allow supermemory-agent-framework
|
||||
uv add supermemory-agent-framework
|
||||
```
|
||||
|
||||
Or with pip:
|
||||
|
||||
```bash
|
||||
pip install --pre supermemory-agent-framework
|
||||
```
|
||||
|
||||
> **Note:** The `--prerelease=allow` / `--pre` flag is required because `agent-framework-core` depends on pre-release versions of Azure packages.
|
||||
|
||||
For async HTTP support (recommended):
|
||||
|
||||
```bash
|
||||
uv add supermemory-agent-framework[async]
|
||||
# or
|
||||
pip install 'supermemory-agent-framework[async]'
|
||||
pip install supermemory-agent-framework
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -38,14 +28,19 @@ The easiest way to add memory capabilities is using the `SupermemoryChatMiddlewa
|
|||
import asyncio
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from supermemory_agent_framework import (
|
||||
AgentSupermemory,
|
||||
SupermemoryChatMiddleware,
|
||||
SupermemoryMiddlewareOptions,
|
||||
)
|
||||
|
||||
async def main():
|
||||
# Create Supermemory middleware
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
connection = AgentSupermemory(
|
||||
api_key="your-supermemory-api-key",
|
||||
container_tag="user-123",
|
||||
)
|
||||
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
connection,
|
||||
options=SupermemoryMiddlewareOptions(
|
||||
mode="full", # "profile", "query", or "full"
|
||||
verbose=True, # Enable logging
|
||||
|
|
@ -77,13 +72,16 @@ The most idiomatic way to add memory in Agent Framework, using the same pattern
|
|||
import asyncio
|
||||
from agent_framework import AgentSession
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from supermemory_agent_framework import SupermemoryContextProvider
|
||||
from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider
|
||||
|
||||
async def main():
|
||||
# Create context provider
|
||||
provider = SupermemoryContextProvider(
|
||||
container_tag="user-123",
|
||||
connection = AgentSupermemory(
|
||||
api_key="your-supermemory-api-key",
|
||||
container_tag="user-123",
|
||||
)
|
||||
|
||||
provider = SupermemoryContextProvider(
|
||||
connection,
|
||||
mode="full",
|
||||
store_conversations=True,
|
||||
)
|
||||
|
|
@ -113,14 +111,14 @@ For explicit tool-based memory access:
|
|||
```python
|
||||
import asyncio
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from supermemory_agent_framework import SupermemoryTools
|
||||
from supermemory_agent_framework import AgentSupermemory, SupermemoryTools
|
||||
|
||||
async def main():
|
||||
# Create memory tools
|
||||
tools = SupermemoryTools(
|
||||
connection = AgentSupermemory(
|
||||
api_key="your-supermemory-api-key",
|
||||
config={"project_id": "my-project"},
|
||||
container_tag="user-123",
|
||||
)
|
||||
tools = SupermemoryTools(connection)
|
||||
|
||||
# Create agent
|
||||
agent = OpenAIResponsesClient().as_agent(
|
||||
|
|
@ -146,6 +144,7 @@ For maximum flexibility, use both middleware (automatic context injection) and t
|
|||
import asyncio
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from supermemory_agent_framework import (
|
||||
AgentSupermemory,
|
||||
SupermemoryChatMiddleware,
|
||||
SupermemoryMiddlewareOptions,
|
||||
SupermemoryTools,
|
||||
|
|
@ -153,14 +152,17 @@ from supermemory_agent_framework import (
|
|||
|
||||
async def main():
|
||||
api_key = "your-supermemory-api-key"
|
||||
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
container_tag="user-123",
|
||||
options=SupermemoryMiddlewareOptions(mode="full"),
|
||||
connection = AgentSupermemory(
|
||||
api_key=api_key,
|
||||
container_tag="user-123",
|
||||
)
|
||||
|
||||
tools = SupermemoryTools(api_key=api_key)
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
connection,
|
||||
options=SupermemoryMiddlewareOptions(mode="full"),
|
||||
)
|
||||
|
||||
tools = SupermemoryTools(connection)
|
||||
|
||||
agent = OpenAIResponsesClient().as_agent(
|
||||
name="MemoryAgent",
|
||||
|
|
@ -217,11 +219,20 @@ SupermemoryMiddlewareOptions(add_memory="never")
|
|||
### Complete Configuration
|
||||
|
||||
```python
|
||||
SupermemoryMiddlewareOptions(
|
||||
conversation_id="chat-session-456", # Group messages into conversations
|
||||
verbose=True, # Enable detailed logging
|
||||
mode="full", # Use both profile and query
|
||||
add_memory="always" # Auto-save conversations
|
||||
connection = AgentSupermemory(
|
||||
api_key="your-supermemory-api-key",
|
||||
container_tag="user-123", # Memory scope
|
||||
conversation_id="chat-session-456", # Groups stored conversations
|
||||
entity_context="User is on the pro plan", # Optional fixed context
|
||||
)
|
||||
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
connection,
|
||||
options=SupermemoryMiddlewareOptions(
|
||||
verbose=True,
|
||||
mode="full",
|
||||
add_memory="always",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -232,13 +243,11 @@ SupermemoryMiddlewareOptions(
|
|||
Memory tools that integrate with Agent Framework's tool system.
|
||||
|
||||
```python
|
||||
tools = SupermemoryTools(
|
||||
connection = AgentSupermemory(
|
||||
api_key="your-api-key",
|
||||
config={
|
||||
"project_id": "my-project", # or use container_tags
|
||||
"base_url": "https://custom.com", # optional
|
||||
}
|
||||
container_tag="user-123",
|
||||
)
|
||||
tools = SupermemoryTools(connection)
|
||||
|
||||
# Get FunctionTool instances for Agent.run()
|
||||
agent_tools = tools.get_tools()
|
||||
|
|
@ -249,26 +258,19 @@ result = await tools.add_memory("User prefers dark mode")
|
|||
result = await tools.get_profile()
|
||||
```
|
||||
|
||||
`search_memories` uses v4 hybrid search, so results can contain either a
|
||||
structured memory or a source chunk. The old Python-only `include_full_docs`
|
||||
argument is deprecated and ignored because v4 search does not return full
|
||||
source documents; it is not exposed to the model as a tool parameter.
|
||||
|
||||
### SupermemoryChatMiddleware
|
||||
|
||||
Chat middleware for automatic memory injection.
|
||||
|
||||
```python
|
||||
middleware = SupermemoryChatMiddleware(
|
||||
container_tag="user-123", # Memory scope identifier
|
||||
connection, # Shared AgentSupermemory connection
|
||||
options=SupermemoryMiddlewareOptions(...),
|
||||
api_key="your-api-key", # Or set SUPERMEMORY_API_KEY env var
|
||||
)
|
||||
```
|
||||
|
||||
### with_supermemory_middleware()
|
||||
|
||||
Convenience function for creating middleware:
|
||||
|
||||
```python
|
||||
middleware = with_supermemory_middleware(
|
||||
"user-123",
|
||||
SupermemoryMiddlewareOptions(mode="full"),
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -278,11 +280,9 @@ Context provider for the Agent Framework session pipeline (like Mem0):
|
|||
|
||||
```python
|
||||
provider = SupermemoryContextProvider(
|
||||
container_tag="user-123",
|
||||
api_key="your-api-key", # Or set SUPERMEMORY_API_KEY env var
|
||||
connection, # Shared AgentSupermemory connection
|
||||
mode="full", # "profile", "query", or "full"
|
||||
store_conversations=True, # Save conversations after each run
|
||||
conversation_id="chat-456", # Optional grouping ID
|
||||
context_prompt="## Memories\n...", # Custom header for injected memories
|
||||
verbose=True, # Enable logging
|
||||
)
|
||||
|
|
@ -292,6 +292,7 @@ provider = SupermemoryContextProvider(
|
|||
|
||||
```python
|
||||
from supermemory_agent_framework import (
|
||||
AgentSupermemory,
|
||||
SupermemoryConfigurationError,
|
||||
SupermemoryAPIError,
|
||||
SupermemoryNetworkError,
|
||||
|
|
@ -299,7 +300,7 @@ from supermemory_agent_framework import (
|
|||
)
|
||||
|
||||
try:
|
||||
middleware = SupermemoryChatMiddleware("user-123")
|
||||
connection = AgentSupermemory(container_tag="user-123")
|
||||
except SupermemoryConfigurationError as e:
|
||||
print(f"Configuration issue: {e}")
|
||||
```
|
||||
|
|
@ -322,11 +323,8 @@ except SupermemoryConfigurationError as e:
|
|||
|
||||
### Required
|
||||
- `agent-framework-core>=1.0.0rc3` - Microsoft Agent Framework
|
||||
- `supermemory>=3.1.0` - Supermemory client
|
||||
- `requests>=2.25.0` - HTTP requests (fallback)
|
||||
|
||||
### Optional
|
||||
- `aiohttp>=3.8.0` - Async HTTP requests (recommended)
|
||||
- `supermemory>=3.16.0` - Supermemory client with v4 hybrid search support
|
||||
- `typing-extensions>=4.0.0` - Typing compatibility helpers
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ classifiers = [
|
|||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc3",
|
||||
"supermemory>=3.1.0",
|
||||
"supermemory>=3.16.0",
|
||||
"typing-extensions>=4.0.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ This is the idiomatic way to integrate persistent memory in Agent Framework,
|
|||
following the same pattern as the built-in Mem0 integration.
|
||||
"""
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
from typing import Any, Literal
|
||||
|
||||
try:
|
||||
from agent_framework import BaseContextProvider
|
||||
from agent_framework import BaseContextProvider # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
# Renamed in agent-framework-core 1.0.0 stable; the interface is
|
||||
# unchanged (source_id __init__, before_run/after_run hooks with
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ Provides FunctionTool-compatible tools that can be passed to Agent.run(tools=[..
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import Annotated, Any, TypedDict
|
||||
import warnings
|
||||
from typing import Annotated, Any, Optional, TypedDict
|
||||
|
||||
from agent_framework import FunctionTool, tool
|
||||
|
||||
|
|
@ -37,6 +38,25 @@ class ProfileResult(TypedDict, total=False):
|
|||
error: str | None
|
||||
|
||||
|
||||
def _to_jsonable(value: Any) -> Any:
|
||||
"""Convert generated SDK models into JSON-compatible structures."""
|
||||
if isinstance(value, dict):
|
||||
return {key: _to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_to_jsonable(item) for item in value]
|
||||
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
try:
|
||||
return _to_jsonable(model_dump(mode="json"))
|
||||
except TypeError:
|
||||
# Compatibility with pydantic-like models whose model_dump does not
|
||||
# accept Pydantic v2's ``mode`` argument.
|
||||
return _to_jsonable(model_dump())
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class SupermemoryTools:
|
||||
"""Memory tools for Microsoft Agent Framework.
|
||||
|
||||
|
|
@ -64,19 +84,28 @@ class SupermemoryTools:
|
|||
async def search_memories(
|
||||
self,
|
||||
information_to_get: Annotated[
|
||||
str, "Terms to search for in the user's memories"
|
||||
str, "Terms to search for in stored memories and source content"
|
||||
],
|
||||
include_full_docs: Annotated[
|
||||
bool,
|
||||
"Whether to include full document content. Defaults to true for better AI context.",
|
||||
] = True,
|
||||
include_full_docs: Optional[bool] = None,
|
||||
limit: Annotated[int, "Maximum number of results to return"] = 10,
|
||||
) -> str:
|
||||
"""Search stored memories for facts, preferences, history, and context. Use proactively before answering whenever memory could help — not only when explicitly asked."""
|
||||
"""Search stored memories and source chunks.
|
||||
|
||||
``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 = await self._client.search.memories(
|
||||
q=information_to_get,
|
||||
container_tags=[self._connection.container_tag],
|
||||
container_tag=self._connection.container_tag,
|
||||
limit=limit,
|
||||
threshold=0.6,
|
||||
search_mode="hybrid",
|
||||
|
|
@ -84,7 +113,7 @@ class SupermemoryTools:
|
|||
results = response.results or []
|
||||
result: MemorySearchResult = {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"results": [_to_jsonable(item) for item in results],
|
||||
"count": len(results),
|
||||
}
|
||||
return json.dumps(result, default=str)
|
||||
|
|
@ -108,7 +137,7 @@ class SupermemoryTools:
|
|||
)
|
||||
result: MemoryAddResult = {
|
||||
"success": True,
|
||||
"memory": response,
|
||||
"memory": _to_jsonable(response),
|
||||
}
|
||||
return json.dumps(result, default=str)
|
||||
except Exception as error:
|
||||
|
|
@ -131,9 +160,13 @@ class SupermemoryTools:
|
|||
response = await self._client.profile(**kwargs)
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"profile": response.profile if hasattr(response, "profile") else None,
|
||||
"profile": (
|
||||
_to_jsonable(response.profile)
|
||||
if hasattr(response, "profile")
|
||||
else None
|
||||
),
|
||||
"search_results": (
|
||||
response.search_results
|
||||
_to_jsonable(response.search_results)
|
||||
if hasattr(response, "search_results")
|
||||
else None
|
||||
),
|
||||
|
|
@ -153,11 +186,11 @@ class SupermemoryTools:
|
|||
tool(
|
||||
name="search_memories",
|
||||
description=(
|
||||
"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 stored memories and source chunks for relevant facts, preferences, "
|
||||
"history, and context. Use proactively whenever prior context could help; "
|
||||
"hybrid results can contain either a memory or a source chunk."
|
||||
),
|
||||
)(self.search_memories),
|
||||
)(self._search_memories_tool),
|
||||
tool(
|
||||
name="add_memory",
|
||||
description=(
|
||||
|
|
@ -175,3 +208,16 @@ class SupermemoryTools:
|
|||
),
|
||||
)(self.get_profile),
|
||||
]
|
||||
|
||||
async def _search_memories_tool(
|
||||
self,
|
||||
information_to_get: Annotated[
|
||||
str, "Terms to search for in stored memories and source content"
|
||||
],
|
||||
limit: Annotated[int, "Maximum number of results to return"] = 10,
|
||||
) -> str:
|
||||
"""Model-facing search wrapper that omits deprecated arguments."""
|
||||
return await self.search_memories(
|
||||
information_to_get=information_to_get,
|
||||
limit=limit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Utility functions for Supermemory Agent Framework integration."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
DEFAULT_CONTEXT_PROMPT = "The following are retrieved memories about the user."
|
||||
|
|
@ -96,18 +97,27 @@ def deduplicate_memories(
|
|||
trimmed = item.strip()
|
||||
return trimmed if trimmed else None
|
||||
if isinstance(item, dict):
|
||||
memory = item.get("memory")
|
||||
if isinstance(memory, str):
|
||||
trimmed = memory.strip()
|
||||
return trimmed if trimmed else None
|
||||
for field in ("memory", "chunk", "content"):
|
||||
value = item.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
# Stainless SDK returns pydantic models (attribute access, snake_case).
|
||||
memory = getattr(item, "memory", None)
|
||||
if isinstance(memory, str):
|
||||
trimmed = memory.strip()
|
||||
return trimmed if trimmed else None
|
||||
for field in ("memory", "chunk", "content"):
|
||||
value = getattr(item, field, None)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
def comparison_key(memory: str) -> str:
|
||||
"""Remove Mono's dynamic-profile date decoration for comparison only."""
|
||||
return re.sub(
|
||||
r"^(?:\[Recent\]\s*)?\[\d{4}-\d{2}-\d{2}\]\s*",
|
||||
"",
|
||||
memory,
|
||||
count=1,
|
||||
).strip()
|
||||
|
||||
static_memories: list[str] = []
|
||||
seen_memories: set[str] = set()
|
||||
|
||||
|
|
@ -115,21 +125,21 @@ def deduplicate_memories(
|
|||
memory = extract_memory_text(item)
|
||||
if memory is not None:
|
||||
static_memories.append(memory)
|
||||
seen_memories.add(memory)
|
||||
seen_memories.add(comparison_key(memory))
|
||||
|
||||
dynamic_memories: list[str] = []
|
||||
for item in dynamic_items:
|
||||
memory = extract_memory_text(item)
|
||||
if memory is not None and memory not in seen_memories:
|
||||
if memory is not None and comparison_key(memory) not in seen_memories:
|
||||
dynamic_memories.append(memory)
|
||||
seen_memories.add(memory)
|
||||
seen_memories.add(comparison_key(memory))
|
||||
|
||||
search_memories: list[str] = []
|
||||
for item in search_items:
|
||||
memory = extract_memory_text(item)
|
||||
if memory is not None and memory not in seen_memories:
|
||||
if memory is not None and comparison_key(memory) not in seen_memories:
|
||||
search_memories.append(memory)
|
||||
seen_memories.add(memory)
|
||||
seen_memories.add(comparison_key(memory))
|
||||
|
||||
return DeduplicatedMemories(
|
||||
static=static_memories,
|
||||
|
|
|
|||
|
|
@ -56,20 +56,6 @@ class TestDeduplicateMemories:
|
|||
)
|
||||
assert result.static == ["valid"]
|
||||
|
||||
def test_pydantic_like_search_results(self) -> None:
|
||||
"""SDK search results are pydantic models, not dicts (#1266)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
result = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
search_results=[
|
||||
SimpleNamespace(memory="User prefers async", updated_at="2026-01-01T00:00:00Z"),
|
||||
SimpleNamespace(memory="User likes Python", updated_at=None),
|
||||
],
|
||||
)
|
||||
assert result.static == ["User likes Python"]
|
||||
assert result.search_results == ["User prefers async"]
|
||||
|
||||
|
||||
class TestConvertProfileToMarkdown:
|
||||
def test_empty_profile(self) -> None:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ yarn add @supermemory/ai-sdk
|
|||
Choose **one** of the following approaches (they cannot be used together):
|
||||
|
||||
- **Infinite Chat Provider**: Connect to various LLM providers with unlimited context support
|
||||
- **Memory Tools**: Search, add, and fetch memories from supermemory using AI agents
|
||||
- **Memory Tools**: Search, add, inspect, and manage Supermemory data using AI agents
|
||||
|
||||
## Infinite Chat Provider
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ The infinite chat provider allows you to connect to various LLM providers with s
|
|||
|
||||
```typescript
|
||||
import { generateText } from 'ai'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
|
||||
// Using a custom provider URL
|
||||
const supermemoryOpenai = createOpenAI({
|
||||
|
|
@ -50,6 +51,7 @@ const result = await generateText({
|
|||
|
||||
```typescript
|
||||
import { generateText } from 'ai'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
|
||||
const supermemoryApiKey = process.env.SUPERMEMORY_API_KEY!
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY!
|
||||
|
|
@ -104,11 +106,12 @@ interface ConfigWithProviderUrl {
|
|||
|
||||
## Memory Tools
|
||||
|
||||
supermemory tools allow AI agents to interact with user memories for enhanced context and personalization.
|
||||
Supermemory tools allow AI agents to search, add, inspect, and manage scoped Supermemory data.
|
||||
|
||||
```typescript
|
||||
import { supermemoryTools } from '@supermemory/ai-sdk'
|
||||
import { generateText } from 'ai'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
|
||||
const result = await generateText({
|
||||
model: openai('gpt-5'),
|
||||
|
|
@ -117,19 +120,15 @@ const result = await generateText({
|
|||
],
|
||||
tools: {
|
||||
...supermemoryTools('your-supermemory-api-key', {
|
||||
// Optional: specify a base URL for self-hosted instances
|
||||
baseUrl: 'https://api.supermemory.com',
|
||||
|
||||
// Use either projectId OR containerTags, not both
|
||||
projectId: 'your-project-id',
|
||||
// OR
|
||||
containerTags: ['tag1', 'tag2']
|
||||
}),
|
||||
// Your other tools go here
|
||||
// Use either projectId OR containerTags, not both.
|
||||
containerTags: ['user-123']
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> **Important:** `supermemoryTools()` includes destructive operations: `documentDelete` permanently deletes a source document, while `memoryForget` soft-forgets an extracted profile memory. Do not expose the complete aggregate to an agent unless it should be allowed to perform those operations.
|
||||
|
||||
### Complete Memory Tools Example
|
||||
|
||||
```typescript
|
||||
|
|
@ -157,7 +156,6 @@ async function chatWithTools(userMessage: string) {
|
|||
containerTags: ['my-user-id']
|
||||
})
|
||||
},
|
||||
maxToolRoundtrips: 5
|
||||
})
|
||||
|
||||
return result.text
|
||||
|
|
@ -167,18 +165,32 @@ async function chatWithTools(userMessage: string) {
|
|||
### Configuration
|
||||
|
||||
```typescript
|
||||
interface SupermemoryConfig {
|
||||
// Optional: Base URL for API calls (default: https://api.supermemory.com)
|
||||
interface SupermemoryToolsConfig {
|
||||
// Optional API base URL (default: https://api.supermemory.ai)
|
||||
baseUrl?: string
|
||||
|
||||
// Container tags for organizing memories (cannot be used with projectId)
|
||||
// One or more non-empty scope tags (cannot be used with projectId)
|
||||
containerTags?: string[]
|
||||
|
||||
// Project ID for scoping memories (cannot be used with containerTags)
|
||||
// Converted to sm_project_<projectId> (cannot be used with containerTags)
|
||||
projectId?: string
|
||||
|
||||
// Enable the package's stricter provider-compatible input schemas
|
||||
// (default: false)
|
||||
strict?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
`projectId` and `containerTags` are mutually exclusive and empty values are rejected. If neither is provided, v2 uses the explicit scope `sm_project_default`. With multiple `containerTags`, operations that support a union use all configured tags; single-profile operations default to the first tag.
|
||||
|
||||
In strict mode, fields covered by a strict schema are required or defaulted. For example, `documentDelete.containerTag` must be a string or `null`; pass `null` to use the configured scope.
|
||||
|
||||
### Migrating from v1
|
||||
|
||||
Version 1 returned only `searchMemories` and `addMemory` from `supermemoryTools()`. Version 2 returns all seven tools listed below, including deletion and forgetting, so review any code that spreads the aggregate directly into an agent.
|
||||
|
||||
Version 1 also left `containerTags` undefined when no scope was configured. Version 2 sends `['sm_project_default']` instead. Before upgrading, choose an explicit `projectId` or `containerTags`, or migrate data that should live in the new default scope.
|
||||
|
||||
### Self-Hosted supermemory
|
||||
|
||||
If you're running a self-hosted supermemory instance:
|
||||
|
|
@ -192,35 +204,17 @@ const tools = supermemoryTools('your-api-key', {
|
|||
|
||||
### Available Tools
|
||||
|
||||
##### Search Memories
|
||||
| Aggregate key | Individual creator | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `searchMemories` | `searchMemoriesTool` | Search stored source documents |
|
||||
| `addMemory` | `addMemoryTool` | Add a short, atomic memory |
|
||||
| `getProfile` | `getProfileTool` | Read static/dynamic profile text and optional query results |
|
||||
| `documentList` | `documentListTool` | List paginated source-document metadata |
|
||||
| `documentDelete` | `documentDeleteTool` | Permanently delete a source and soft-forget its extracted memories |
|
||||
| `documentAdd` | `documentAddTool` | Ingest a source document for asynchronous processing |
|
||||
| `memoryForget` | `memoryForgetTool` | Soft-forget one extracted profile memory |
|
||||
|
||||
Search through user memories using semantic matching.
|
||||
|
||||
```typescript
|
||||
const searchResult = await tools.searchMemories.execute({
|
||||
informationToGet: 'user preferences about coffee'
|
||||
})
|
||||
```
|
||||
|
||||
##### Add Memory
|
||||
|
||||
Add new memories to the user's memory store.
|
||||
|
||||
```typescript
|
||||
const addResult = await tools.addMemory.execute({
|
||||
memory: 'User prefers dark roast coffee in the morning'
|
||||
})
|
||||
```
|
||||
|
||||
##### Fetch Memory
|
||||
|
||||
Retrieve a specific memory by its ID.
|
||||
|
||||
```typescript
|
||||
const fetchResult = await tools.fetchMemory.execute({
|
||||
memoryId: 'memory-id-123'
|
||||
})
|
||||
```
|
||||
There is no `fetchMemory` or `fetchMemoryTool`. Use `getProfile` for profile memories, `searchMemories` for relevant source content, and `documentList` for source-document IDs and metadata.
|
||||
|
||||
### Using Individual Tools
|
||||
|
||||
|
|
@ -230,7 +224,11 @@ For more flexibility, you can import and use individual tools:
|
|||
import {
|
||||
searchMemoriesTool,
|
||||
addMemoryTool,
|
||||
fetchMemoryTool
|
||||
getProfileTool,
|
||||
documentListTool,
|
||||
documentDeleteTool,
|
||||
documentAddTool,
|
||||
memoryForgetTool
|
||||
} from '@supermemory/ai-sdk'
|
||||
|
||||
const searchTool = searchMemoriesTool('your-api-key', {
|
||||
|
|
@ -247,6 +245,22 @@ const result = await generateText({
|
|||
})
|
||||
```
|
||||
|
||||
To expose a non-destructive subset, create the aggregate once and select only the tools the agent needs:
|
||||
|
||||
```typescript
|
||||
const allTools = supermemoryTools('your-api-key', {
|
||||
containerTags: ['user-123']
|
||||
})
|
||||
|
||||
const safeTools = {
|
||||
searchMemories: allTools.searchMemories,
|
||||
addMemory: allTools.addMemory,
|
||||
getProfile: allTools.getProfile,
|
||||
documentList: allTools.documentList,
|
||||
documentAdd: allTools.documentAdd
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
All tool executions return a result object with a `success` field:
|
||||
|
|
@ -269,33 +283,24 @@ if (result.success) {
|
|||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bun test
|
||||
# From the repository root
|
||||
bun run --cwd packages/ai-sdk test:unit
|
||||
|
||||
# Run tests in watch mode
|
||||
bun test --watch
|
||||
# Or from packages/ai-sdk
|
||||
bun run test:unit
|
||||
```
|
||||
|
||||
#### Environment Variables for Tests
|
||||
|
||||
All tests require API keys to run. Copy `.env.example` to `.env` and set the required values:
|
||||
Local initialization and unit checks do not require API keys. Network integration checks run only when both of these are set; otherwise they are skipped:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Required:**
|
||||
- `SUPERMEMORY_API_KEY`: Your Supermemory API key
|
||||
- `PROVIDER_API_KEY`: Your AI provider API key (OpenAI, Anthropic, etc.)
|
||||
- `OPENAI_API_KEY`: Your OpenAI API key for tool integration tests
|
||||
- `SUPERMEMORY_API_KEY`: Supermemory API key
|
||||
- `OPENAI_API_KEY`: OpenAI API key
|
||||
|
||||
**Optional:**
|
||||
- `SUPERMEMORY_BASE_URL`: Custom Supermemory base URL (defaults to `https://api.supermemory.ai`)
|
||||
- `PROVIDER_NAME`: Provider name (defaults to `openai`) - one of: `openai`, `anthropic`, `openrouter`, `deepinfra`, `groq`, `google`, `cloudflare`
|
||||
- `PROVIDER_URL`: Custom provider URL (use instead of `PROVIDER_NAME`)
|
||||
- `MODEL_NAME`: Model to use in tests (defaults to `gpt-3.5-turbo`)
|
||||
|
||||
Tests will fail if required API keys are not provided.
|
||||
- `SUPERMEMORY_BASE_URL`: Custom Supermemory base URL
|
||||
- `MODEL_NAME`: OpenAI model used by integration checks (defaults to `gpt-5-nano`)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"name": "@supermemory/ai-sdk",
|
||||
"type": "module",
|
||||
"version": "1.0.9",
|
||||
"version": "2.0.0",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch --ignore-watch .turbo",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run src/tools.unit.test.ts",
|
||||
"test:unit": "vitest run src/tools.test.ts",
|
||||
"test:watch": "vitest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.22",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@supermemory/tools": "workspace:*",
|
||||
"@supermemory/tools": "^2.2.0",
|
||||
"ai": "^5.0.113",
|
||||
"supermemory": "^4.25.4"
|
||||
},
|
||||
|
|
@ -24,9 +24,12 @@
|
|||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index-B8qmWxBg.d.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./package.json": "./package.json"
|
||||
|
|
|
|||
|
|
@ -5,25 +5,15 @@ import { type SupermemoryToolsConfig, supermemoryTools } from "./tools"
|
|||
|
||||
import "dotenv/config"
|
||||
|
||||
describe.skipIf(
|
||||
!process.env.SUPERMEMORY_API_KEY || !process.env.OPENAI_API_KEY,
|
||||
)("supermemoryTools", () => {
|
||||
// Required API keys — suite is skipped in CI without them
|
||||
const testApiKey = process.env.SUPERMEMORY_API_KEY as string
|
||||
const testOpenAIKey = process.env.OPENAI_API_KEY as string
|
||||
|
||||
// Optional configuration with defaults
|
||||
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined
|
||||
const testModelName = process.env.MODEL_NAME || "gpt-5-nano"
|
||||
|
||||
const testPrompts = [
|
||||
"What do you remember about my preferences?",
|
||||
"Help me plan my day based on what you know about me",
|
||||
"What are my current projects?",
|
||||
"Remind me of my interests and hobbies",
|
||||
"What should I focus on today?",
|
||||
]
|
||||
const hasIntegrationKeys = Boolean(
|
||||
process.env.SUPERMEMORY_API_KEY && process.env.OPENAI_API_KEY,
|
||||
)
|
||||
const testApiKey = process.env.SUPERMEMORY_API_KEY ?? "test-api-key"
|
||||
const testOpenAIKey = process.env.OPENAI_API_KEY ?? "test-openai-key"
|
||||
const testBaseUrl = process.env.SUPERMEMORY_BASE_URL ?? undefined
|
||||
const testModelName = process.env.MODEL_NAME || "gpt-5-nano"
|
||||
|
||||
describe("supermemoryTools", () => {
|
||||
describe("client initialization", () => {
|
||||
it("should create tools with default configuration", () => {
|
||||
const config: SupermemoryToolsConfig = {}
|
||||
|
|
@ -73,7 +63,7 @@ describe.skipIf(
|
|||
})
|
||||
})
|
||||
|
||||
describe("AI SDK integration", () => {
|
||||
describe.skipIf(!hasIntegrationKeys)("AI SDK integration", () => {
|
||||
it("should work with AI SDK generateText", async () => {
|
||||
const openai = createOpenAI({
|
||||
apiKey: testOpenAIKey,
|
||||
|
|
@ -89,7 +79,7 @@ describe.skipIf(
|
|||
},
|
||||
{
|
||||
role: "user",
|
||||
content: testPrompts[0]!,
|
||||
content: "What do you remember about my preferences?",
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { getContainerTags } from "./tools"
|
||||
|
||||
describe("getContainerTags", () => {
|
||||
it("defaults to the default project when no config is provided", () => {
|
||||
expect(getContainerTags()).toEqual(["sm_project_default"])
|
||||
})
|
||||
|
||||
it("converts projectId into a project container tag", () => {
|
||||
expect(getContainerTags({ projectId: "abc" })).toEqual(["sm_project_abc"])
|
||||
})
|
||||
|
||||
it("uses explicit container tags", () => {
|
||||
expect(getContainerTags({ containerTags: ["tag-a", "tag-b"] })).toEqual([
|
||||
"tag-a",
|
||||
"tag-b",
|
||||
])
|
||||
})
|
||||
|
||||
it("rejects config with both projectId and containerTags", () => {
|
||||
expect(() =>
|
||||
getContainerTags({
|
||||
projectId: "abc",
|
||||
containerTags: ["tag-a"],
|
||||
}),
|
||||
).toThrow("either projectId or containerTags")
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ export default defineConfig({
|
|||
target: "es2020",
|
||||
tsconfig: "./tsconfig.json",
|
||||
clean: true,
|
||||
hash: false,
|
||||
minify: true,
|
||||
dts: {
|
||||
sourcemap: true,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ async def get_agent(env, call_request):
|
|||
# Create base LLM agent
|
||||
base_agent = LlmAgent(
|
||||
model="gemini/gemini-2.5-flash-preview-09-2025",
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
config=LlmConfig(
|
||||
system_prompt="You are a helpful voice assistant with memory.",
|
||||
introduction="Hello! Great to talk with you again!"
|
||||
|
|
@ -101,7 +102,7 @@ read_only_agent = SupermemoryCartesiaAgent(
|
|||
|
||||
1. **Intercepts events** - Listens for `UserTurnEnded` events from Cartesia Line
|
||||
2. **Retrieves memories** - Queries Supermemory `/v4/profile` API with user's message
|
||||
3. **Enriches context** - Adds memories to event history as system message
|
||||
3. **Enriches context** - Passes memories as non-persistent context for the current turn
|
||||
4. **Stores messages** - Sends conversation to Supermemory (background, non-blocking)
|
||||
5. **Passes to agent** - Forwards enriched event to wrapped LlmAgent
|
||||
|
||||
|
|
@ -135,7 +136,7 @@ UserTurnEnded Event {content: "user message", history: [...]}
|
|||
│ 1. Intercept UserTurnEnded │
|
||||
│ 2. Extract user message │
|
||||
│ 3. Query Supermemory API │
|
||||
│ 4. Enrich event.history with memories │
|
||||
│ 4. Add memories as per-turn context │
|
||||
│ 5. Pass to wrapped LlmAgent │
|
||||
│ 6. Store conversation (async background) │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
|
@ -155,7 +156,7 @@ Audio Output
|
|||
| **Event Handling** | `process_frame()` method | `process()` method |
|
||||
| **Events** | `LLMContextFrame`, `LLMMessagesFrame` | `UserTurnEnded`, `CallStarted` |
|
||||
| **Context Object** | `LLMContext.get_messages()` | `event.history` |
|
||||
| **Memory Injection** | Modify `context.add_message()` | Modify `event.history` |
|
||||
| **Memory Injection** | Modify `context.add_message()` | Pass per-turn `context` |
|
||||
|
||||
## Full Example with Tools
|
||||
|
||||
|
|
@ -183,6 +184,7 @@ async def get_agent(env, call_request):
|
|||
# Create LLM agent with tools
|
||||
base_agent = LlmAgent(
|
||||
model="gemini/gemini-2.5-flash-preview-09-2025",
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
tools=[weather_tool],
|
||||
config=LlmConfig(
|
||||
system_prompt="You are a personal assistant with memory and tools.",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ classifiers = [
|
|||
]
|
||||
dependencies = [
|
||||
"supermemory>=3.16.0",
|
||||
"cartesia-line>=0.2.0",
|
||||
"cartesia-line>=0.2.0,<0.3.0",
|
||||
"pydantic>=2.10.0",
|
||||
"loguru>=0.7.3",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
supermemory>=3.16.0
|
||||
pydantic>=2.10.0
|
||||
loguru>=0.7.3
|
||||
cartesia-line>=0.2.0
|
||||
cartesia-line>=0.2.0,<0.3.0
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ enabling persistent memory and context enhancement for voice AI applications.
|
|||
|
||||
Example:
|
||||
```python
|
||||
import os
|
||||
|
||||
from supermemory_cartesia import SupermemoryCartesiaAgent, MemoryConfig
|
||||
from line.llm_agent import LlmAgent, LlmConfig
|
||||
|
||||
# Create base LLM agent
|
||||
base_agent = LlmAgent(
|
||||
model="gemini/gemini-2.5-flash-preview-09-2025",
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
config=LlmConfig(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
introduction="Hello!"
|
||||
|
|
@ -22,6 +25,7 @@ Example:
|
|||
agent=base_agent,
|
||||
api_key=os.getenv("SUPERMEMORY_API_KEY"),
|
||||
container_tag="user-123",
|
||||
custom_id="conversation-456",
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
|
@ -46,7 +50,13 @@ from .utils import (
|
|||
get_last_user_message,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
__version__ = version("supermemory-cartesia")
|
||||
except PackageNotFoundError:
|
||||
# Source checkouts do not have installed distribution metadata.
|
||||
__version__ = "0.1.2"
|
||||
|
||||
__all__ = [
|
||||
# Main agent
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Cartesia Line voice agents, adding persistent memory and context enrichment.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
from typing import Any, AsyncGenerator, Dict, List, Literal, Optional
|
||||
|
|
@ -13,7 +14,7 @@ from loguru import logger
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from .exceptions import ConfigurationError, MemoryRetrievalError
|
||||
from .utils import deduplicate_memories, format_memories_to_text
|
||||
from .utils import _field, deduplicate_memories, format_memories_to_text
|
||||
|
||||
try:
|
||||
import supermemory
|
||||
|
|
@ -34,8 +35,7 @@ class SupermemoryCartesiaAgent:
|
|||
"""Memory-enhanced wrapper for Cartesia Line agents.
|
||||
|
||||
This wrapper intercepts UserTurnEnded events, retrieves relevant memories
|
||||
from Supermemory, and enriches the conversation history before passing to
|
||||
the wrapped agent.
|
||||
from Supermemory, and passes them as per-turn context to the wrapped agent.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -44,6 +44,7 @@ class SupermemoryCartesiaAgent:
|
|||
|
||||
base_agent = LlmAgent(
|
||||
model="anthropic/claude-haiku-4-5-20251001",
|
||||
api_key=os.getenv("ANTHROPIC_API_KEY"),
|
||||
config=LlmConfig(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
introduction="Hello! How can I help you today?"
|
||||
|
|
@ -141,8 +142,8 @@ class SupermemoryCartesiaAgent:
|
|||
except Exception as e:
|
||||
logger.error(f"[Supermemory] Failed to initialize client: {e}")
|
||||
|
||||
self._messages_sent_count: int = 0
|
||||
self._last_query: Optional[str] = None
|
||||
self._history_cursor: List[Dict[str, str]] = []
|
||||
self._last_retrieval_event: Optional[str] = None
|
||||
self._background_tasks: set = set() # Track background tasks to prevent GC
|
||||
|
||||
async def _retrieve_memories(self, query: str) -> Dict[str, Any]:
|
||||
|
|
@ -159,7 +160,6 @@ class SupermemoryCartesiaAgent:
|
|||
if self.config.mode != "profile" and query:
|
||||
kwargs["q"] = query
|
||||
kwargs["threshold"] = self.config.search_threshold
|
||||
kwargs["extra_body"] = {"limit": self.config.search_limit}
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self._supermemory_client.profile(**kwargs),
|
||||
|
|
@ -169,17 +169,14 @@ class SupermemoryCartesiaAgent:
|
|||
# A user with no stored memories yet gets a null profile back, which
|
||||
# is a normal case, not an error. Guard against it so we return an
|
||||
# empty profile instead of raising AttributeError on response.profile.
|
||||
profile = getattr(response, "profile", None)
|
||||
profile_static = (
|
||||
profile.static if profile is not None and profile.static else []
|
||||
)
|
||||
profile_dynamic = (
|
||||
profile.dynamic if profile is not None and profile.dynamic else []
|
||||
)
|
||||
profile = _field(response, "profile")
|
||||
profile_static = list(_field(profile, "static", default=[]) or [])
|
||||
profile_dynamic = list(_field(profile, "dynamic", default=[]) or [])
|
||||
|
||||
search_results: List[Any] = []
|
||||
if response.search_results and response.search_results.results:
|
||||
search_results = list(response.search_results.results)
|
||||
search_response = _field(response, "search_results", "searchResults")
|
||||
raw_search_results = _field(search_response, "results", default=[]) or []
|
||||
search_results = list(raw_search_results)[: self.config.search_limit]
|
||||
|
||||
logger.info(
|
||||
f"[Supermemory] Retrieved memories - static: {len(profile_static)}, "
|
||||
|
|
@ -296,54 +293,73 @@ class SupermemoryCartesiaAgent:
|
|||
return str(content)
|
||||
|
||||
def _extract_conversation_from_history(self, history: list) -> List[Dict[str, str]]:
|
||||
"""Extract messages from Cartesia event history."""
|
||||
messages = []
|
||||
seen = set()
|
||||
"""Extract messages, suppressing only adjacent duplicate representations."""
|
||||
messages: List[Dict[str, str]] = []
|
||||
|
||||
def append_message(role: str, content: Any) -> None:
|
||||
if role not in ("user", "assistant") or not isinstance(content, str) or not content:
|
||||
return
|
||||
message = {"role": role, "content": content}
|
||||
if not messages or messages[-1] != message:
|
||||
messages.append(message)
|
||||
|
||||
for item in history:
|
||||
if isinstance(item, dict):
|
||||
if item.get("role") in ("user", "assistant"):
|
||||
content = item.get("content", "")
|
||||
if content and content not in seen:
|
||||
messages.append(item)
|
||||
seen.add(content)
|
||||
append_message(item["role"], item.get("content", ""))
|
||||
continue
|
||||
|
||||
event_type = getattr(item, 'type', None) or type(item).__name__
|
||||
event_type = getattr(item, "type", None) or type(item).__name__
|
||||
|
||||
if event_type in ('user_turn_ended', 'UserTurnEnded'):
|
||||
nested = getattr(item, 'content', [])
|
||||
if event_type in ("user_turn_ended", "UserTurnEnded"):
|
||||
nested = getattr(item, "content", [])
|
||||
if isinstance(nested, list):
|
||||
for n in nested:
|
||||
if hasattr(n, 'content') and isinstance(n.content, str):
|
||||
if n.content not in seen:
|
||||
messages.append({"role": "user", "content": n.content})
|
||||
seen.add(n.content)
|
||||
for nested_item in nested:
|
||||
if hasattr(nested_item, "content"):
|
||||
append_message("user", nested_item.content)
|
||||
|
||||
elif event_type in ('agent_turn_ended', 'AgentTurnEnded'):
|
||||
nested = getattr(item, 'content', [])
|
||||
elif event_type in ("agent_turn_ended", "AgentTurnEnded"):
|
||||
nested = getattr(item, "content", [])
|
||||
if isinstance(nested, list):
|
||||
texts = [n.content for n in nested if hasattr(n, 'content') and isinstance(n.content, str)]
|
||||
texts = [
|
||||
nested_item.content
|
||||
for nested_item in nested
|
||||
if hasattr(nested_item, "content") and isinstance(nested_item.content, str)
|
||||
]
|
||||
if texts:
|
||||
content = " ".join(texts)
|
||||
if content not in seen:
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
seen.add(content)
|
||||
append_message("assistant", " ".join(texts))
|
||||
|
||||
elif event_type in ('user_text_sent', 'UserTextSent'):
|
||||
content = getattr(item, 'content', '')
|
||||
if content and isinstance(content, str) and content not in seen:
|
||||
messages.append({"role": "user", "content": content})
|
||||
seen.add(content)
|
||||
elif event_type in ("user_text_sent", "UserTextSent"):
|
||||
append_message("user", getattr(item, "content", ""))
|
||||
|
||||
elif event_type in ('agent_text_sent', 'AgentTextSent'):
|
||||
content = getattr(item, 'content', '')
|
||||
if content and isinstance(content, str) and content not in seen:
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
seen.add(content)
|
||||
elif event_type in ("agent_text_sent", "AgentTextSent"):
|
||||
append_message("assistant", getattr(item, "content", ""))
|
||||
|
||||
return messages
|
||||
|
||||
def _new_messages_from_sequence(
|
||||
self,
|
||||
current_messages: List[Dict[str, str]],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Return the append after the longest previous-suffix/current-prefix overlap."""
|
||||
if current_messages and len(current_messages) <= len(self._history_cursor):
|
||||
for start in range(len(self._history_cursor) - len(current_messages) + 1):
|
||||
if self._history_cursor[start : start + len(current_messages)] == current_messages:
|
||||
return []
|
||||
|
||||
overlap = 0
|
||||
for size in range(min(len(self._history_cursor), len(current_messages)), 0, -1):
|
||||
if self._history_cursor[-size:] == current_messages[:size]:
|
||||
overlap = size
|
||||
break
|
||||
|
||||
self._history_cursor = current_messages
|
||||
return current_messages[overlap:]
|
||||
|
||||
def _new_history_messages(self, history: list) -> List[Dict[str, str]]:
|
||||
"""Return only messages appended to a cumulative or front-truncated history."""
|
||||
return self._new_messages_from_sequence(self._extract_conversation_from_history(history))
|
||||
|
||||
async def _enrich_event_with_memories(self, event: Any) -> tuple[Any, Optional[str]]:
|
||||
"""Enrich event by retrieving memories.
|
||||
|
||||
|
|
@ -357,14 +373,16 @@ class SupermemoryCartesiaAgent:
|
|||
logger.warning("[Supermemory] Could not extract user message from event")
|
||||
return event, None
|
||||
|
||||
if user_message == self._last_query:
|
||||
event_id = _field(event, "event_id", "eventId")
|
||||
event_marker = f"event:{event_id}" if event_id else f"object:{id(event)}"
|
||||
if event_marker == self._last_retrieval_event:
|
||||
return event, None
|
||||
|
||||
self._last_query = user_message
|
||||
logger.info(f"[Supermemory] Processing user message: {user_message[:50]}...")
|
||||
|
||||
try:
|
||||
memories_data = await self._retrieve_memories(user_message)
|
||||
self._last_retrieval_event = event_marker
|
||||
memory_context = self._build_memory_message(memories_data)
|
||||
|
||||
if not memory_context:
|
||||
|
|
@ -381,6 +399,69 @@ class SupermemoryCartesiaAgent:
|
|||
logger.error(f"[Supermemory] Error in memory enrichment: {e}")
|
||||
return event, None
|
||||
|
||||
def _agent_accepts_context(self) -> bool:
|
||||
"""Return whether the wrapped agent supports per-call context."""
|
||||
try:
|
||||
parameters = inspect.signature(self.agent.process).parameters.values()
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
return any(
|
||||
parameter.name == "context" or parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in parameters
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _without_memory_context(prompt: str) -> str:
|
||||
"""Remove context previously injected by this wrapper."""
|
||||
return re.sub(
|
||||
rf"{re.escape(MEMORY_TAG_START)}.*?{re.escape(MEMORY_TAG_END)}\s*",
|
||||
"",
|
||||
prompt,
|
||||
flags=re.DOTALL,
|
||||
).strip()
|
||||
|
||||
async def _process_agent(
|
||||
self,
|
||||
env: Any,
|
||||
event: Event,
|
||||
memory_context: Optional[str],
|
||||
) -> AsyncGenerator[Event, None]:
|
||||
"""Call modern Line agents with per-turn context, with a legacy fallback."""
|
||||
if self._agent_accepts_context():
|
||||
process_kwargs = {"context": memory_context} if memory_context else {}
|
||||
async for output in self.agent.process(env, event, **process_kwargs):
|
||||
yield output
|
||||
return
|
||||
|
||||
# Cartesia Line 0.2.0-0.2.2 exposed a mutable ``config`` property and
|
||||
# did not yet support per-call context. Keep that narrow compatibility
|
||||
# path while avoiding persistent memory text in the base prompt.
|
||||
legacy_config = getattr(self.agent, "config", None)
|
||||
if legacy_config is None:
|
||||
if memory_context:
|
||||
logger.warning(
|
||||
"[Supermemory] Wrapped agent cannot accept memory context; "
|
||||
"forwarding the event unchanged"
|
||||
)
|
||||
async for output in self.agent.process(env, event):
|
||||
yield output
|
||||
return
|
||||
|
||||
original_prompt = getattr(legacy_config, "system_prompt", "") or ""
|
||||
clean_prompt = self._without_memory_context(str(original_prompt))
|
||||
prompt_for_call = (
|
||||
f"{memory_context}\n\n{clean_prompt}"
|
||||
if memory_context and clean_prompt
|
||||
else memory_context or clean_prompt
|
||||
)
|
||||
legacy_config.system_prompt = prompt_for_call
|
||||
try:
|
||||
async for output in self.agent.process(env, event):
|
||||
yield output
|
||||
finally:
|
||||
legacy_config.system_prompt = clean_prompt
|
||||
|
||||
async def process(self, env: Any, event: Event) -> AsyncGenerator[Event, None]:
|
||||
"""Process events with memory enrichment.
|
||||
|
||||
|
|
@ -396,49 +477,31 @@ class SupermemoryCartesiaAgent:
|
|||
logger.info("[Supermemory] Processing UserTurnEnded event")
|
||||
event, memory_context = await self._enrich_event_with_memories(event)
|
||||
|
||||
# Clean up old memory context and inject new one if available
|
||||
if hasattr(self.agent, 'config'):
|
||||
original_prompt = getattr(self.agent.config, 'system_prompt', '')
|
||||
# Always remove old memory context if present to prevent stale data
|
||||
if MEMORY_TAG_START in original_prompt:
|
||||
original_prompt = re.sub(
|
||||
rf'{re.escape(MEMORY_TAG_START)}.*?{re.escape(MEMORY_TAG_END)}\s*',
|
||||
'',
|
||||
original_prompt,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
logger.debug("[Supermemory] Removed old memory context from system prompt")
|
||||
|
||||
# Inject new memory context if available
|
||||
if memory_context:
|
||||
self.agent.config.system_prompt = f"{memory_context}\n\n{original_prompt}"
|
||||
logger.info("[Supermemory] Injected new memory context into system prompt")
|
||||
else:
|
||||
# No new memories, but we cleaned up old ones
|
||||
self.agent.config.system_prompt = original_prompt
|
||||
logger.debug("[Supermemory] No new memories to inject, using clean prompt")
|
||||
|
||||
# Store conversation in background
|
||||
if hasattr(event, 'history') and event.history:
|
||||
messages = self._extract_conversation_from_history(event.history)
|
||||
unsent = messages[self._messages_sent_count:]
|
||||
if unsent:
|
||||
logger.info(f"[Supermemory] Queuing {len(unsent)} messages for storage")
|
||||
task = asyncio.create_task(self._store_messages(unsent))
|
||||
new_messages = self._new_history_messages(event.history)
|
||||
if new_messages:
|
||||
logger.info(
|
||||
f"[Supermemory] Queuing {len(new_messages)} messages for storage"
|
||||
)
|
||||
task = asyncio.create_task(self._store_messages(new_messages))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
self._messages_sent_count = len(messages)
|
||||
else:
|
||||
# No history yet, store just the current user message
|
||||
user_content = self._extract_user_message(event)
|
||||
if user_content:
|
||||
logger.info(f"[Supermemory] No history, storing current user message: {user_content[:50]}...")
|
||||
task = asyncio.create_task(self._store_messages([{"role": "user", "content": user_content}]))
|
||||
current_messages = self._extract_conversation_from_history([event])
|
||||
if not current_messages:
|
||||
user_content = self._extract_user_message(event)
|
||||
if user_content:
|
||||
current_messages = [{"role": "user", "content": user_content}]
|
||||
new_messages = self._new_messages_from_sequence(current_messages)
|
||||
if new_messages:
|
||||
logger.info("[Supermemory] No history, storing current user message")
|
||||
task = asyncio.create_task(self._store_messages(new_messages))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
self._messages_sent_count = 1 # CRITICAL: Increment counter to prevent duplicate storage
|
||||
|
||||
async for output in self.agent.process(env, event):
|
||||
async for output in self._process_agent(env, event, memory_context):
|
||||
yield output
|
||||
else:
|
||||
async for output in self.agent.process(env, event):
|
||||
|
|
@ -451,6 +514,6 @@ class SupermemoryCartesiaAgent:
|
|||
|
||||
def reset_memory_tracking(self) -> None:
|
||||
"""Reset memory tracking for a new conversation."""
|
||||
self._messages_sent_count = 0
|
||||
self._last_query = None
|
||||
self._history_cursor = []
|
||||
self._last_retrieval_event = None
|
||||
logger.info("[Supermemory] Reset memory tracking state")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Utility functions for Supermemory Cartesia integration."""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
|
|
@ -69,6 +70,18 @@ def _field(item: Any, *names: str, default: Any = None) -> Any:
|
|||
return default
|
||||
|
||||
|
||||
_MEMORY_DATE_PREFIX = re.compile(
|
||||
r"^\s*(?:\[recent\]\s*)?(?:\[\d{4}-\d{2}-\d{2}\]\s*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _memory_key(memory: str) -> str:
|
||||
"""Normalize display-only profile prefixes for duplicate comparison."""
|
||||
without_prefix = _MEMORY_DATE_PREFIX.sub("", memory)
|
||||
return " ".join(without_prefix.split()).casefold()
|
||||
|
||||
|
||||
def deduplicate_memories(
|
||||
static: List[str],
|
||||
dynamic: List[str],
|
||||
|
|
@ -86,8 +99,11 @@ def deduplicate_memories(
|
|||
def unique_strings(memories: List[str]) -> List[str]:
|
||||
out = []
|
||||
for m in memories:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
if not isinstance(m, str):
|
||||
continue
|
||||
key = _memory_key(m)
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
|
@ -99,8 +115,9 @@ def deduplicate_memories(
|
|||
if not isinstance(memory, str):
|
||||
memory = ""
|
||||
memory = memory.strip()
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
key = _memory_key(memory)
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
"""Regression tests for pydantic/dict memory helpers (#1266)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
from supermemory_cartesia.utils import deduplicate_memories, format_memories_to_text
|
||||
|
||||
|
||||
class TestDeduplicateMemories(unittest.TestCase):
|
||||
def test_accepts_dict_search_results(self) -> None:
|
||||
result = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
dynamic=[],
|
||||
search_results=[{"memory": "User prefers async", "updatedAt": "2026-01-01T00:00:00Z"}],
|
||||
)
|
||||
self.assertEqual(result["static"], ["User likes Python"])
|
||||
self.assertEqual(len(result["search_results"]), 1)
|
||||
|
||||
def test_accepts_pydantic_like_search_results(self) -> None:
|
||||
# Mirrors supermemory.types.search_memories_response.Result
|
||||
model = SimpleNamespace(
|
||||
id="mem_1",
|
||||
similarity=0.9,
|
||||
memory="User prefers async",
|
||||
updated_at="2026-01-01T00:00:00Z",
|
||||
)
|
||||
result = deduplicate_memories(
|
||||
static=[],
|
||||
dynamic=[],
|
||||
search_results=[model],
|
||||
)
|
||||
self.assertEqual(len(result["search_results"]), 1)
|
||||
self.assertIs(result["search_results"][0], model)
|
||||
|
||||
def test_dedupes_model_against_static_string(self) -> None:
|
||||
model = SimpleNamespace(memory="User likes Python", updated_at=None)
|
||||
result = deduplicate_memories(
|
||||
static=["User likes Python"],
|
||||
dynamic=[],
|
||||
search_results=[model],
|
||||
)
|
||||
self.assertEqual(result["search_results"], [])
|
||||
|
||||
|
||||
class TestFormatMemoriesToText(unittest.TestCase):
|
||||
def test_formats_pydantic_like_search_results(self) -> None:
|
||||
text = format_memories_to_text(
|
||||
{
|
||||
"static": [],
|
||||
"dynamic": [],
|
||||
"search_results": [
|
||||
SimpleNamespace(
|
||||
memory="User prefers async",
|
||||
updated_at="2020-01-01T00:00:00Z",
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertIn("User prefers async", text)
|
||||
self.assertIn("Relevant Memories", text)
|
||||
|
||||
def test_formats_search_execute_content_field(self) -> None:
|
||||
text = format_memories_to_text(
|
||||
{
|
||||
"static": [],
|
||||
"dynamic": [],
|
||||
"search_results": [
|
||||
SimpleNamespace(
|
||||
content="User owns a telescope",
|
||||
updated_at="2020-01-01T00:00:00Z",
|
||||
memory=None,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertIn("User owns a telescope", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -71,10 +71,6 @@ class TestSupermemoryCartesiaNullProfile(unittest.IsolatedAsyncioTestCase):
|
|||
"search_results": [],
|
||||
},
|
||||
)
|
||||
agent._supermemory_client.profile.assert_awaited_once()
|
||||
kwargs = agent._supermemory_client.profile.await_args.kwargs
|
||||
self.assertEqual(kwargs["container_tag"], "user-123")
|
||||
self.assertEqual(kwargs["q"], "Hello world")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue