mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Compare commits
41 commits
server-v0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d8a4ebfdd | ||
|
|
9a0c5a5ad6 | ||
|
|
17eab43cd4 | ||
|
|
4ad5f0beb1 | ||
|
|
03773c4f2e | ||
|
|
d0f53b0d64 | ||
|
|
b01d2b69a3 | ||
|
|
7974498062 | ||
|
|
4173edb9e6 | ||
|
|
c262cc9953 | ||
|
|
879ddd5c95 | ||
|
|
46d1b53230 | ||
|
|
de3bbb3ce9 | ||
|
|
5fee2f2872 | ||
|
|
ece20ff53e | ||
|
|
348483d5e8 | ||
|
|
c5b7e7d4fc | ||
|
|
143024fa34 | ||
|
|
9ccd1b64c3 | ||
|
|
d436792e77 | ||
|
|
29c43984fe | ||
|
|
f11d8c4620 | ||
|
|
9652478093 | ||
|
|
3f7b9667c6 | ||
|
|
e4afc770be | ||
|
|
f051af098e | ||
|
|
6cae175852 | ||
|
|
3b0fc9c959 | ||
|
|
3487666481 | ||
|
|
dda56e766e | ||
|
|
818a83a381 | ||
|
|
7b1175cb1a | ||
|
|
20410a6862 | ||
|
|
7d59070ad6 | ||
|
|
18a2dfbe39 | ||
|
|
149589ae7e | ||
|
|
c0eb81c887 | ||
|
|
e2be9c9edd | ||
|
|
5d2b5855fe | ||
|
|
d14b209f7c | ||
|
|
e651045ac5 |
196 changed files with 15035 additions and 5528 deletions
257
.github/workflows/ci-python.yml
vendored
Normal file
257
.github/workflows/ci-python.yml
vendored
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
name: CI - Python SDKs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "packages/agent-framework-python/**"
|
||||||
|
- "packages/cartesia-sdk-python/**"
|
||||||
|
- "packages/openai-sdk-python/**"
|
||||||
|
- "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 (${{ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/agent-framework-python/pyproject.toml
|
||||||
|
|
||||||
|
- 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: python -m pytest
|
||||||
|
|
||||||
|
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- 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: 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 (${{ 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-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
|
||||||
|
|
||||||
|
- 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 (${{ 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-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
|
||||||
|
|
||||||
|
- 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)"
|
||||||
79
.github/workflows/ci.yml
vendored
79
.github/workflows/ci.yml
vendored
|
|
@ -26,8 +26,83 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run TypeScript type checking
|
- name: Detect SDK and playground changes
|
||||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
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
|
||||||
|
|
||||||
|
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- apps/sdk-playground; then
|
||||||
|
echo "sdk_playground=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "sdk_playground=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Python for SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Setup uv for SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: true
|
||||||
|
working-directory: apps/sdk-playground/python
|
||||||
|
cache-dependency-glob: uv.lock
|
||||||
|
|
||||||
|
- name: Validate SDK Playground Python server
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
working-directory: apps/sdk-playground/python
|
||||||
|
run: |
|
||||||
|
uv sync --locked --python 3.12
|
||||||
|
.venv/bin/python -m py_compile server.py
|
||||||
|
.venv/bin/python -c "import server"
|
||||||
|
|
||||||
|
- 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' || steps.sdk-changes.outputs.sdk_playground == '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' || steps.sdk-changes.outputs.sdk_playground == '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' || steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd packages/ai-sdk build
|
||||||
|
|
||||||
|
- name: Run SDK Playground type checking
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd apps/sdk-playground check-types:app
|
||||||
|
|
||||||
|
- name: Build SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd apps/sdk-playground build:app
|
||||||
|
|
||||||
|
- name: Run Memory Graph type checking
|
||||||
|
run: bun run --cwd packages/memory-graph check-types
|
||||||
|
|
||||||
|
- name: Run Memory Graph unit tests
|
||||||
|
run: bun run --cwd packages/memory-graph test
|
||||||
|
|
||||||
- name: Run Biome CI (format & lint on changed files)
|
- name: Run Biome CI (format & lint on changed files)
|
||||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,22 @@ jobs:
|
||||||
working-directory: ./packages/agent-framework-python
|
working-directory: ./packages/agent-framework-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/agent-framework-python/dist/
|
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:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "packages/ai-sdk/package.json"
|
- "packages/ai-sdk/package.json"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
defaults:
|
defaults:
|
||||||
|
|
@ -38,26 +38,65 @@ jobs:
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
working-directory: .
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Check if version changed
|
- name: Check if version changed
|
||||||
id: version-check
|
id: version-check
|
||||||
run: |
|
run: |
|
||||||
PACKAGE_NAME=$(jq -r '.name' package.json)
|
PACKAGE_NAME=$(jq -r '.name' package.json)
|
||||||
LOCAL_VERSION=$(jq -r '.version' package.json)
|
LOCAL_VERSION=$(jq -r '.version' package.json)
|
||||||
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
|
if npm view "$PACKAGE_NAME@$LOCAL_VERSION" version >/dev/null 2>&1; then
|
||||||
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
|
|
||||||
echo "Version $LOCAL_VERSION already published, skipping."
|
echo "Version $LOCAL_VERSION already published, skipping."
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
|
echo "Publishing $LOCAL_VERSION."
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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'
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
run: bun run build
|
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
|
- name: Publish
|
||||||
if: steps.version-check.outputs.changed == 'true'
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
run: npm publish --access public --provenance
|
run: npm publish --access public --provenance
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,22 @@ jobs:
|
||||||
working-directory: ./packages/cartesia-sdk-python
|
working-directory: ./packages/cartesia-sdk-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/cartesia-sdk-python/dist/
|
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
|
working-directory: ./packages/pipecat-sdk-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/pipecat-sdk-python/dist/
|
packages-dir: packages/pipecat-sdk-python/dist/
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@
|
||||||
"dev:firefox": "wxt -b firefox",
|
"dev:firefox": "wxt -b firefox",
|
||||||
"build": "wxt build",
|
"build": "wxt build",
|
||||||
"build:firefox": "wxt build -b firefox",
|
"build:firefox": "wxt build -b firefox",
|
||||||
"check-types": "bun run compile",
|
"check-types": "wxt prepare && tsc --noEmit",
|
||||||
"zip": "wxt zip",
|
"zip": "wxt zip",
|
||||||
"zip:firefox": "wxt zip -b firefox",
|
"zip:firefox": "wxt zip -b firefox",
|
||||||
"compile": "tsc --noEmit",
|
|
||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ Uploading a long PDF does more than store bytes: Supermemory derives many memori
|
||||||
## Properties and rules of memories
|
## Properties and rules of memories
|
||||||
|
|
||||||
1. Memories are atomic - Each memory has enough information and context about one particular topic
|
1. Memories are atomic - Each memory has enough information and context about one particular topic
|
||||||
2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledg.e
|
2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledge.
|
||||||
|
|
||||||
## Memory relationships
|
## Memory relationships
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,8 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"integrations/openclaw",
|
"integrations/openclaw",
|
||||||
"integrations/claude-code",
|
"integrations/claude-code",
|
||||||
|
"integrations/cursor",
|
||||||
|
"integrations/grok-bot",
|
||||||
"integrations/opencode",
|
"integrations/opencode",
|
||||||
"integrations/codex",
|
"integrations/codex",
|
||||||
"integrations/hermes"
|
"integrations/hermes"
|
||||||
|
|
|
||||||
1
apps/docs/images/cursor-logo.svg
Normal file
1
apps/docs/images/cursor-logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg fill="none" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m96 73h320.735v365.65h-320.735z"/></clipPath><path d="m512.001 325.499c0 7.124 0 14.24-.041 21.364-.035 5.999-.103 11.998-.268 17.99-.356 13.068-1.124 26.245-3.448 39.169-2.359 13.109-6.205 25.306-12.266 37.222-5.958 11.703-13.746 22.419-23.029 31.709-9.29 9.291-20 17.072-31.71 23.03-11.909 6.061-24.113 9.907-37.222 12.266-12.923 2.324-26.101 3.092-39.169 3.448-5.999.165-11.991.233-17.99.268-7.123.048-14.24.041-21.364.041h-139c-7.124 0-14.24 0-21.364-.041-5.999-.035-11.998-.103-17.99-.268-13.068-.356-26.245-1.124-39.169-3.448-13.109-2.359-25.306-6.205-37.2219-12.266-11.7034-5.958-22.4195-13.746-31.7095-23.03-9.29-9.29-17.0717-19.999-23.0296-31.709-6.06082-11.909-9.90709-24.113-12.26559-37.222-2.32422-12.924-3.092104-26.101-3.448621-39.169-.164547-5.999-.2331075-11.991-.267388-17.99-.02742444-7.124-.02742444-14.24-.02742444-21.364v-139c0-7.124 0-14.24.04113664-21.364.0342805-5.999.1028418-11.998.2673878-17.99.356517-13.068 1.124399-26.245 3.448619-39.169 2.3585-13.1091 6.20477-25.3061 12.26558-37.222 5.9579-11.7034 13.7465-22.4195 23.0296-31.7095 9.2901-9.29 19.9993-17.0717 31.7095-23.0296 11.9091-6.06082 24.1129-9.90709 37.2222-12.26559 12.923-2.32422 26.101-3.092101 39.168-3.448618 6-.164547 11.992-.2331075 17.991-.2673881 7.117-.03428046 14.233-.03428046 21.357-.03428046h139c7.124 0 14.24 0 21.364.04113656 5.999.0342806 11.998.102842 17.99.267388 13.068.356517 26.245 1.124402 39.169 3.448622 13.109 2.3585 25.306 6.20477 37.222 12.26553 11.703 5.958 22.419 13.7465 31.709 23.0297 9.29 9.29 17.072 19.9992 23.03 31.7094 6.061 11.9091 9.907 24.113 12.266 37.2222 2.324 12.923 3.092 26.101 3.448 39.169.165 5.999.233 11.991.268 17.99.048 7.123.041 14.24.041 21.364v139z" fill="#14120b"/><path d="m186.501 3.99902h139c7.125 0 14.231-.00004 21.341.04102 5.985.0342 11.952.10221 17.903.26562h.001c13.003.35475 25.943 1.11709 38.569 3.3877 12.775 2.29828 24.592 6.03144 36.118 11.89354v-.001c10.973 5.5867 21.052 12.8384 29.848 21.4571l.847.8379c8.993 8.993 16.525 19.3593 22.292 30.6933l3.565-1.8135-3.565 1.8145c5.678 11.1575 9.36 22.6024 11.674 34.9208l.219 1.195c2.129 11.837 2.932 23.95 3.316 36.132l.072 2.438c.164 5.958.232 11.919.266 17.904v.004c.048 7.107.041 14.207.041 21.337v129.344l-.007-.007v9.656c0 7.125 0 14.231-.041 21.341-.034 5.985-.102 11.952-.266 17.903v.001c-.354 13.003-1.117 25.944-3.387 38.569-2.227 12.376-5.8 23.853-11.351 35.037l-.543 1.08c-5.767 11.327-13.306 21.701-22.293 30.695-8.993 8.993-19.361 16.526-30.695 22.293-11.518 5.861-23.342 9.595-36.116 11.894-11.837 2.128-23.95 2.931-36.132 3.315l-2.438.072c-5.958.164-11.919.232-17.904.266h-.004c-7.107.048-14.207.041-21.337.041h-139c-7.125 0-14.231 0-21.341-.041-5.985-.034-11.952-.102-17.903-.266h-.001c-13.003-.355-25.944-1.117-38.57-3.387-12.7742-2.299-24.5914-6.032-36.1165-11.894h.001c-10.974-5.587-21.0534-12.837-29.8496-21.456l-.8467-.838c-8.7118-8.712-16.0531-18.713-21.7461-29.634l-.5459-1.06-.544-1.081c-5.3709-10.816-8.8904-21.919-11.12983-33.84l-.21973-1.196c-2.27061-12.625-3.03294-25.566-3.38769-38.569v-.001c-.16342-5.958-.23143-11.918-.26563-17.903-.02736-7.112-.02734-14.219-.02734-21.341v-139c0-7.125-.00005-14.231.04101-21.341.0342-5.985.10222-11.952.26563-17.903v-.001c.35475-13.003 1.11699-25.944 3.38769-38.57 2.29829-12.7743 6.03159-24.5916 11.89359-36.1166l-.001-.001c5.7665-11.3269 13.3073-21.6999 22.2939-30.6934 8.9933-8.9932 19.36-16.5261 30.6944-22.2929h.0009c11.5174-5.8615 23.3407-9.59617 36.1149-11.89455 11.837-2.12878 23.951-2.93017 36.133-3.31446l2.438-.07226c5.958-.16342 11.919-.23142 17.904-.26563l-.001-.00097c7.104-.03422 14.211-.03321 21.335-.03321z" stroke="#edecec" stroke-opacity=".2" stroke-width="8"/><g clip-path="url(#a)"><path d="m410.344 159.545-146.38-84.5111c-4.7-2.7145-10.5-2.7145-15.2 0l-146.373 84.5111c-3.9515 2.282-6.391 6.501-6.391 11.071v170.418c0 4.569 2.4395 8.789 6.391 11.07l146.379 84.512c4.701 2.714 10.501 2.714 15.201 0l146.38-84.512c3.951-2.281 6.391-6.501 6.391-11.07v-170.418c0-4.57-2.44-8.789-6.391-11.071zm-9.195 17.902-141.308 244.751c-.955 1.65-3.477.976-3.477-.934v-160.261c0-3.203-1.711-6.164-4.487-7.772l-138.786-80.127c-1.65-.956-.976-3.478.934-3.478h282.616c4.013 0 6.522 4.35 4.515 7.828h-.007z" fill="#edecec"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/docs/images/grok-logo.png
Normal file
BIN
apps/docs/images/grok-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -145,7 +145,7 @@ Per-repo overrides in `.claude/.supermemory-claude/config.json`. Run `/supermemo
|
||||||
Source code, issues, and detailed README.
|
Source code, issues, and detailed README.
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="OpenClaw Plugin" icon="messages-square" href="/integrations/openclaw">
|
<Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
|
||||||
Multi-platform memory for Telegram, WhatsApp, Discord, and more.
|
Memory for your Cursor chats.
|
||||||
</Card>
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ tail -f ~/.codex-supermemory.log
|
||||||
Source code, issues, and detailed README.
|
Source code, issues, and detailed README.
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Claude Code Plugin" icon="code" href="/integrations/claude-code">
|
<Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
|
||||||
Memory plugin for Claude Code.
|
Memory for your Cursor chats.
|
||||||
</Card>
|
</Card>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|
|
||||||
208
apps/docs/integrations/cursor.mdx
Normal file
208
apps/docs/integrations/cursor.mdx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
---
|
||||||
|
title: "Cursor"
|
||||||
|
sidebarTitle: "Cursor"
|
||||||
|
description: "cursor-supermemory: persistent memory across your Cursor chats"
|
||||||
|
icon: "/images/cursor-logo.svg"
|
||||||
|
---
|
||||||
|
|
||||||
|
Your agent remembers the decisions, bugs, and conventions from earlier chats instead of starting cold every time.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Requires [Node.js](https://nodejs.org) on your `PATH`. Installing Cursor does not put one there.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
Run this in Cursor:
|
||||||
|
|
||||||
|
```
|
||||||
|
/add-plugin cursor-supermemory
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install it from the [Cursor Marketplace](https://cursor.com/marketplace/supermemory): open **Customize**, find **Supermemory**, select **Install**, and choose **project** or **user** scope.
|
||||||
|
|
||||||
|
Restart Cursor or run **Developer: Reload Window** afterwards.
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
Open a new chat in Cursor and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
/supermemory-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser window opens. Sign in to Supermemory and you are done.
|
||||||
|
|
||||||
|
As a fallback, set an API key from [API Keys](https://console.supermemory.ai/keys):
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="macOS / Linux (zsh)">
|
||||||
|
```bash
|
||||||
|
echo 'export SUPERMEMORY_API_KEY="sm_..."' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="macOS / Linux (bash)">
|
||||||
|
```bash
|
||||||
|
echo 'export SUPERMEMORY_API_KEY="sm_..."' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Windows (PowerShell)">
|
||||||
|
```powershell
|
||||||
|
[System.Environment]::SetEnvironmentVariable("SUPERMEMORY_API_KEY", "sm_...", "User")
|
||||||
|
```
|
||||||
|
Restart your terminal after running this.
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
Restart Cursor after installing the plugin or changing credentials.
|
||||||
|
|
||||||
|
Check the connection any time with `/supermemory-status`.
|
||||||
|
|
||||||
|
<Accordion title="Prefer the terminal?" icon="terminal">
|
||||||
|
The slash commands just run the plugin's CLI for you. To drive it yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" login
|
||||||
|
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" status
|
||||||
|
node "${CURSOR_PLUGIN_ROOT}/dist/cli.js" logout
|
||||||
|
```
|
||||||
|
|
||||||
|
`CURSOR_PLUGIN_ROOT` is set for plugin hooks. If it is empty in your shell, run `node dist/cli.js <command>` from the installed plugin directory.
|
||||||
|
|
||||||
|
Credentials are stored in `~/.supermemory-cursor/credentials.json`.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
| Layer | What it does |
|
||||||
|
|-------|--------------|
|
||||||
|
| Session profile | Loads your persistent profile when a Cursor conversation starts |
|
||||||
|
| Automatic recall | Searches on substantive prompts, deduplicates results, and injects them after the first supported tool result |
|
||||||
|
| Incremental capture | Saves each completed turn, and retries unsaved transcript deltas at session end |
|
||||||
|
| MCP tools | Explicit memory control from any Cursor AI session |
|
||||||
|
| Context gatherer | Fans out targeted searches before substantial work |
|
||||||
|
| Always-on rule | Makes the agent recall relevant history proactively |
|
||||||
|
|
||||||
|
### Skills and Commands
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `memory-init` | Skill | Explore the codebase and initialize project memory |
|
||||||
|
| `memory-save` | Skill | Save an insight, decision, or solution worth keeping |
|
||||||
|
| `memory-search` | Skill | Search memory for past work, bugs, and decisions |
|
||||||
|
| `supermemory-context-gatherer` | Agent | Gather deep background before substantial work |
|
||||||
|
| `supermemory-setup` | Command | Connect Supermemory to Cursor |
|
||||||
|
| `supermemory-status` | Command | Check authentication and live connectivity |
|
||||||
|
| `supermemory-config` | Command | Create or edit the project config file |
|
||||||
|
| `supermemory-logout` | Command | Disconnect Supermemory from Cursor |
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `supermemory_get_config` | Show current config, resolved container tags, and config file paths |
|
||||||
|
| `supermemory_set_config` | Update config at project or global scope |
|
||||||
|
| `supermemory_containers` | Show what `user` and `project` container tags resolve to |
|
||||||
|
| `supermemory_search` | Search memories by query |
|
||||||
|
| `supermemory_add` | Save new information to memory |
|
||||||
|
| `supermemory_list` | List stored memories |
|
||||||
|
| `supermemory_forget` | Delete a memory by id or content |
|
||||||
|
| `supermemory_profile` | Get your user profile summary |
|
||||||
|
|
||||||
|
Every tool that takes a `container` argument accepts:
|
||||||
|
|
||||||
|
- `"user"` (default): personal memories for the current repository
|
||||||
|
- `"project"`: project knowledge for the current repository
|
||||||
|
- `"both"`: both scopes plus compatible legacy memories
|
||||||
|
- any custom string: used as a raw container tag
|
||||||
|
|
||||||
|
`user` and `project` write to the same repository container. The `sm_scope` metadata field is what keeps personal and session memories separate from explicit project knowledge when an agent asks for one scope.
|
||||||
|
|
||||||
|
## Container Tags
|
||||||
|
|
||||||
|
Cursor shares one repository tag with the [Claude Code](/integrations/claude-code), [OpenAI Codex](/integrations/codex), and [OpenCode](/integrations/opencode) plugins, so agents working on the same repo read and write the same memory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repo_<project_name>__<project_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The project ID is a stable hash of the normalized Git remote. Repositories without a remote fall back to their resolved local path. Two repos with the same directory name never collide, and different agents on the same repository share memory.
|
||||||
|
|
||||||
|
The plugin still reads the former `cursor_user_*` and `cursor_project_*` tags, along with legacy tags from the other agents. New writes only use the unified repository tag. Set `repoContainerTag` only when you need an explicit shared override.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview): run `npx supermemory local`, then set `SUPERMEMORY_API_URL="http://localhost:6767"` (or `baseUrl` in your config file) and use the API key printed on first boot.
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SUPERMEMORY_API_KEY` | API key (overrides all other sources) |
|
||||||
|
| `SUPERMEMORY_API_URL` | Override the Supermemory API base URL |
|
||||||
|
| `SUPERMEMORY_REPO_TAG` | Override the unified repository container tag |
|
||||||
|
| `SUPERMEMORY_USER_TAG` | Legacy Cursor personal container to continue reading |
|
||||||
|
| `SUPERMEMORY_PROJECT_TAG` | Legacy Cursor project container to continue reading |
|
||||||
|
| `CURSOR_USER_EMAIL` | Used only to find legacy Cursor personal memories |
|
||||||
|
|
||||||
|
### Global config
|
||||||
|
|
||||||
|
`~/.config/cursor/supermemory.json` holds user-wide defaults and applies to all projects.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repoContainerTag": "repo_my_project__0123456789abcdef",
|
||||||
|
"similarityThreshold": 0.55,
|
||||||
|
"maxMemories": 10,
|
||||||
|
"injectProfile": true,
|
||||||
|
"signalExtraction": false,
|
||||||
|
"signalKeywords": ["remember", "architecture", "decision", "bug", "fix"],
|
||||||
|
"signalTurnsBefore": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project config
|
||||||
|
|
||||||
|
`.cursor/.supermemory/config.json` holds per-workspace overrides and wins over global config. Add it to `.gitignore` if it contains an API key.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "sm_...",
|
||||||
|
"repoContainerTag": "repo_my_project__0123456789abcdef",
|
||||||
|
"similarityThreshold": 0.55,
|
||||||
|
"maxMemories": 10,
|
||||||
|
"injectProfile": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `apiKey` | — | Project-specific API key |
|
||||||
|
| `baseUrl` | Supermemory API | Override the Supermemory API base URL |
|
||||||
|
| `repoContainerTag` | derived from normalized Git remote or project path | Override the unified repository container |
|
||||||
|
| `userContainerTag` | — | Legacy Cursor personal container to continue reading |
|
||||||
|
| `projectContainerTag` | — | Legacy Cursor project container to continue reading |
|
||||||
|
| `similarityThreshold` | `0.55` | Minimum similarity for prompt recall. Values below `0.55` are floored. |
|
||||||
|
| `maxMemories` | `10` | Max profile facts injected at session start |
|
||||||
|
| `injectProfile` | `true` | Whether to inject the user profile at session start |
|
||||||
|
| `signalExtraction` | `false` | Capture only turns containing durable-signal keywords |
|
||||||
|
| `signalKeywords` | `remember`, `architecture`, `decision`, `bug`, `fix` | Keywords that trigger signal-based capture |
|
||||||
|
| `signalTurnsBefore` | `3` | Number of nearby turns retained around a signal |
|
||||||
|
|
||||||
|
You can also set these from the agent with `supermemory_set_config`, or edit the file by hand.
|
||||||
|
|
||||||
|
## Log Out
|
||||||
|
|
||||||
|
Run `/supermemory-logout` in Cursor.
|
||||||
|
|
||||||
|
This removes the stored credentials. Your memories in Supermemory are preserved.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
<Card title="GitHub Repository" icon="/images/github-icon.svg" href="https://github.com/supermemoryai/cursor-supermemory">
|
||||||
|
Source code, issues, and detailed README.
|
||||||
|
</Card>
|
||||||
64
apps/docs/integrations/grok-bot.mdx
Normal file
64
apps/docs/integrations/grok-bot.mdx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
title: "Grok Bot"
|
||||||
|
sidebarTitle: "Grok Bot"
|
||||||
|
description: "Supermemory for Grok Bot: persistent memory for your Grok Bots"
|
||||||
|
icon: "/images/grok-logo.png"
|
||||||
|
---
|
||||||
|
|
||||||
|
Grok Bots are cloud agents. A new task can mean a new machine and a blank context. Supermemory is the memory they keep between those tasks — findings, decisions, and preferences — so the next Bot does not start cold.
|
||||||
|
|
||||||
|
## How it helps
|
||||||
|
|
||||||
|
Without memory, a Bot redoes investigation you already paid for. With Supermemory:
|
||||||
|
|
||||||
|
- Later Bots know what earlier ones already figured out
|
||||||
|
- You can save a decision or finding once and have it stick
|
||||||
|
- You can ask what it already remembers instead of starting over
|
||||||
|
|
||||||
|
Install once, sign in, and it is available to your Grok Bots.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Open the [Supermemory for Grok Bot plugin page](https://x.ai/bot/plugin/58578698) and select **Add to Grok Bot**.
|
||||||
|
|
||||||
|
Restart Grok Bot afterwards so the plugin loads.
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
Just ask Grok Bot:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sign me in to Supermemory
|
||||||
|
```
|
||||||
|
|
||||||
|
It shows a connect card. Follow it to link your Supermemory account.
|
||||||
|
|
||||||
|
To check later, ask `Is Supermemory connected?`
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Grok Bot picks these up from what you ask. There are no slash commands.
|
||||||
|
|
||||||
|
| Skill | Ask for it like this |
|
||||||
|
|-------|----------------------|
|
||||||
|
| `memory-init` | "Learn this codebase and remember it" |
|
||||||
|
| `memory-save` | "Remember that we use Vitest for unit tests" |
|
||||||
|
| `memory-search` | "What do you remember about our database schema?" |
|
||||||
|
|
||||||
|
## Log Out
|
||||||
|
|
||||||
|
Ask Grok Bot to `Disconnect Supermemory`.
|
||||||
|
|
||||||
|
This removes the stored credentials. Your memories in Supermemory are preserved.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="GitHub Repository" icon="/images/github-icon.svg" href="https://github.com/supermemoryai/cursor-supermemory">
|
||||||
|
Source code, issues, and detailed README.
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Cursor Plugin" icon="/images/cursor-logo.svg" href="/integrations/cursor">
|
||||||
|
The same plugin, installed through Cursor.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
|
|
@ -18,7 +18,7 @@ Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent),
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @supermemory/tools @voltagent/core
|
npm install @supermemory/tools @voltagent/core ai@^6 @ai-sdk/openai@^3
|
||||||
```
|
```
|
||||||
|
|
||||||
Set up your API key as an environment variable:
|
Set up your API key as an environment variable:
|
||||||
|
|
@ -52,9 +52,7 @@ const configWithMemory = withSupermemory({
|
||||||
const agent = new Agent(configWithMemory)
|
const agent = new Agent(configWithMemory)
|
||||||
|
|
||||||
// Memories are automatically injected and saved
|
// Memories are automatically injected and saved
|
||||||
const result = await agent.generateText({
|
const result = await agent.generateText("What's my name?")
|
||||||
messages: [{ role: "user", content: "What's my name?" }],
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
|
|
@ -131,14 +129,13 @@ const configWithMemory = withSupermemory({
|
||||||
|
|
||||||
// Search tuning
|
// Search tuning
|
||||||
searchMode: "hybrid", // "memories" | "documents" | "hybrid"
|
searchMode: "hybrid", // "memories" | "documents" | "hybrid"
|
||||||
threshold: 0.1, // 0.0-1.0 (higher = more accurate)
|
threshold: 0.6, // 0.0-1.0 (higher = more accurate)
|
||||||
limit: 10, // Max results to return
|
limit: 10, // Integer from 1 to 100
|
||||||
rerank: true, // Rerank for best relevance
|
rerank: true, // Rerank for best relevance
|
||||||
rewriteQuery: false, // AI-rewrite query (+400ms latency)
|
rewriteQuery: false, // AI-rewrite query (+400ms latency)
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
entityContext: "This is John, a software engineer", // Guides memory extraction (max 1500 chars)
|
metadata: { source: "voltagent" }, // Attached to saved conversations
|
||||||
metadata: { source: "voltagent" }, // Attached to saved conversations
|
|
||||||
|
|
||||||
// API
|
// API
|
||||||
apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var
|
apiKey: "sk-...", // Falls back to SUPERMEMORY_API_KEY env var
|
||||||
|
|
@ -154,14 +151,16 @@ const configWithMemory = withSupermemory({
|
||||||
| `addMemory` | string | `"always"` | Whether to save conversations after each response |
|
| `addMemory` | string | `"always"` | Whether to save conversations after each response |
|
||||||
| `customId` | string | **required** | Custom ID to group messages into a conversation |
|
| `customId` | string | **required** | Custom ID to group messages into a conversation |
|
||||||
| `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` |
|
| `searchMode` | string | — | `"memories"`, `"documents"`, or `"hybrid"` |
|
||||||
| `threshold` | number | `0.1` | Similarity threshold (0 = more results, 1 = more accurate) |
|
| `threshold` | number | — | Similarity threshold (0 = more results, 1 = more accurate) |
|
||||||
| `limit` | number | `10` | Maximum number of memory results |
|
| `limit` | number | — | Maximum number of memory results (integer from 1 to 100) |
|
||||||
| `rerank` | boolean | `false` | Rerank results for relevance |
|
| `rerank` | boolean | `false` | Rerank results for relevance |
|
||||||
| `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) |
|
| `rewriteQuery` | boolean | `false` | AI-rewrite query for better results (+400ms) |
|
||||||
| `entityContext` | string | — | Context for memory extraction (max 1500 chars) |
|
| `entityContext` | string | — | Deprecated and ignored. [Configure it on the container tag instead](/concepts/customization#entity-context). |
|
||||||
| `metadata` | object | — | Custom metadata attached to saved conversations |
|
| `metadata` | object | — | Custom metadata attached to saved conversations |
|
||||||
| `promptTemplate` | function | — | Custom function to format memory data into prompt |
|
| `promptTemplate` | function | — | Custom function to format memory data into prompt |
|
||||||
|
|
||||||
|
When `threshold` or `limit` is omitted, the selected Supermemory backend route applies its own default. Set them explicitly when you need consistent search tuning across modes.
|
||||||
|
|
||||||
## Search Modes
|
## Search Modes
|
||||||
|
|
||||||
The `searchMode` option controls what type of results are searched:
|
The `searchMode` option controls what type of results are searched:
|
||||||
|
|
@ -171,4 +170,3 @@ The `searchMode` option controls what type of results are searched:
|
||||||
| `"memories"` | Search only memory entries (atomic facts about the user) |
|
| `"memories"` | Search only memory entries (atomic facts about the user) |
|
||||||
| `"documents"` | Search only document chunks |
|
| `"documents"` | Search only document chunks |
|
||||||
| `"hybrid"` | Search both memories AND document chunks (recommended) |
|
| `"hybrid"` | Search both memories AND document chunks (recommended) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ A malicious or buggy client with a correctly scoped key cannot read another cont
|
||||||
|
|
||||||
### Data use
|
### Data use
|
||||||
|
|
||||||
Supermemory is infrastructure for *your* agents. Paid production usage is not treated as free training corpus for unrelated public models. For contractual wording (DPA, subprocessors, training policies), request the latest legal pack from support.
|
Supermemory is infrastructure for *your* agents. Your customer content is never used to train models — this applies to every plan, free or paid, with no difference between them. For contractual wording (DPA, subprocessors, training policies), request the latest legal pack from support.
|
||||||
|
|
||||||
### Data residency and deployment options
|
### Data residency and deployment options
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,14 @@ Use the dimension published for your chosen model. A mismatch with vectors alrea
|
||||||
|
|
||||||
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
|
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Model Mixing Bug in v0.0.5 (Exact match returns nothing)**
|
||||||
|
>
|
||||||
|
> In version `v0.0.5`, there was a bug where the server could mix different embedding models between write and read paths (e.g., document ingestion using OpenAI but memory queries using local default embeddings). In multilingual contexts like Japanese (which lacks space tokenization for fallback lexical FTS matching), this caused exact-text memory searches through `/v4/search` and `/v4/profile` to silently return `{"results":[],"total":0}`.
|
||||||
|
>
|
||||||
|
> **Resolution:**
|
||||||
|
> This was fully resolved in `v0.0.7` by locking the embedding plan uniformly across all document and query embedding paths (enforced via a locked plan in the database store). If you are running `v0.0.5` and experiencing this issue, you should upgrade to `v0.0.7` or later.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
|
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ On the Plugins page, select the **plus icon** in the upper-right corner.
|
||||||
Complete the **New Plugin** form:
|
Complete the **New Plugin** form:
|
||||||
|
|
||||||
- In **Name**, enter `Supermemory MCP`.
|
- In **Name**, enter `Supermemory MCP`.
|
||||||
- In **Description**, enter `Memory/context for you ai agents`.
|
- In **Description**, enter `Memory/context for your AI agents`.
|
||||||
- Under **Connection**, select **Server URL** instead of **Tunnel**.
|
- Under **Connection**, select **Server URL** instead of **Tunnel**.
|
||||||
- In the URL field, paste `https://mcp.supermemory.ai/mcp`.
|
- In the URL field, paste `https://mcp.supermemory.ai/mcp`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||||
import { fetchSession, validateOAuthToken } from "./index"
|
import {
|
||||||
|
fetchSession,
|
||||||
|
TransientAuthError,
|
||||||
|
validateApiKey,
|
||||||
|
validateOAuthToken,
|
||||||
|
} from "./index"
|
||||||
|
|
||||||
const API_URL = "https://api.example.com"
|
const API_URL = "https://api.example.com"
|
||||||
const ISSUER = `${API_URL}/api/auth`
|
const ISSUER = `${API_URL}/api/auth`
|
||||||
|
|
@ -120,4 +125,92 @@ describe("MCP authentication", () => {
|
||||||
status: 403,
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces a 500 from the session endpoint as TransientAuthError, not invalid token", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
validateApiKey("sm_outage_key_0123456789abcdef", API_URL),
|
||||||
|
).rejects.toThrow(TransientAuthError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces a session-endpoint timeout as TransientAuthError", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockRejectedValue(
|
||||||
|
Object.assign(new Error("The operation was aborted"), {
|
||||||
|
name: "TimeoutError",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
validateApiKey("sm_timeout_key_0123456789abcd", API_URL),
|
||||||
|
).rejects.toThrow(TransientAuthError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,93 @@ export async function fetchSession(
|
||||||
return result.data
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream was unreachable, not the token being bad: reporting these as invalid_token makes clients discard working credentials.
|
||||||
|
export class TransientAuthError extends Error {
|
||||||
|
readonly status?: number
|
||||||
|
|
||||||
|
constructor(message: string, status?: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = "TransientAuthError"
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSIENT_ERROR_NAMES = new Set([
|
||||||
|
"AbortError",
|
||||||
|
"TimeoutError",
|
||||||
|
"JWKSTimeout",
|
||||||
|
])
|
||||||
|
|
||||||
|
// ERR_JOSE_GENERIC is what jose throws when the JWKS endpoint answers non-200 or unparseable JSON.
|
||||||
|
const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"])
|
||||||
|
|
||||||
|
function transientAuthErrorFor(error: unknown): TransientAuthError | null {
|
||||||
|
const status = (error as { status?: unknown } | null)?.status
|
||||||
|
if (typeof status === "number" && status !== 401 && status !== 403) {
|
||||||
|
return new TransientAuthError(`Session endpoint returned ${status}`, status)
|
||||||
|
}
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
return new TransientAuthError(`Auth backend unreachable: ${error.message}`)
|
||||||
|
}
|
||||||
|
if (error instanceof Error && TRANSIENT_ERROR_NAMES.has(error.name)) {
|
||||||
|
return new TransientAuthError(error.message)
|
||||||
|
}
|
||||||
|
const code = (error as { code?: unknown } | null)?.code
|
||||||
|
if (typeof code === "string" && TRANSIENT_JOSE_CODES.has(code)) {
|
||||||
|
return new TransientAuthError(
|
||||||
|
`JWKS fetch failed: ${(error as Error).message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const transient = transientAuthErrorFor(error)
|
||||||
|
if (transient) throw transient
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateOAuthToken(
|
export async function validateOAuthToken(
|
||||||
token: string,
|
token: string,
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
|
|
@ -97,6 +184,8 @@ export async function validateOAuthToken(
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("OAuth token validation error:", error)
|
console.error("OAuth token validation error:", error)
|
||||||
|
const transient = transientAuthErrorFor(error)
|
||||||
|
if (transient) throw transient
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import { z } from "zod"
|
||||||
import {
|
import {
|
||||||
containerTagSchema,
|
containerTagSchema,
|
||||||
documentsApiResponseSchema,
|
documentsApiResponseSchema,
|
||||||
paginationSchema,
|
memoriesListSchema,
|
||||||
type ContainerTag,
|
type ContainerTag,
|
||||||
type DocumentMemoryEntry,
|
type DocumentMemoryEntry,
|
||||||
type DocumentsApiResponse,
|
type DocumentsApiResponse,
|
||||||
type DocumentWithMemories,
|
type DocumentWithMemories,
|
||||||
|
type MemoriesList,
|
||||||
|
type MemoryEntry,
|
||||||
|
type MemoryEntryHistory,
|
||||||
} from "../../shared/types"
|
} from "../../shared/types"
|
||||||
|
|
||||||
const MAX_CHARS = 200000
|
const MAX_CHARS = 200000
|
||||||
|
|
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
|
||||||
pagination: SdkDocumentListResponse["pagination"]
|
pagination: SdkDocumentListResponse["pagination"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryEntryHistorySchema = z.looseObject({
|
// Memory-entry shapes live in shared/types so the client parser and the
|
||||||
id: z.string(),
|
// listMemories output schema share one definition and can't drift.
|
||||||
memory: z.string(),
|
export type { MemoryEntry, MemoryEntryHistory }
|
||||||
version: z.number(),
|
export type MemoryEntriesResponse = MemoriesList
|
||||||
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>
|
|
||||||
|
|
||||||
export type Memory =
|
export type Memory =
|
||||||
| {
|
| {
|
||||||
|
|
@ -452,7 +422,7 @@ export class SupermemoryClient {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return memoryEntriesResponseSchema.parse(await response.json())
|
return memoriesListSchema.parse(await response.json())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleError(error)
|
this.handleError(error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
|
||||||
import { createMcpHandler } from "agents/mcp/server"
|
import { createMcpHandler } from "agents/mcp/server"
|
||||||
import { Hono, type Context } from "hono"
|
import { Hono, type Context } from "hono"
|
||||||
import { cors } from "hono/cors"
|
import { cors } from "hono/cors"
|
||||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
import {
|
||||||
|
isApiKey,
|
||||||
|
TransientAuthError,
|
||||||
|
validateApiKey,
|
||||||
|
validateOAuthToken,
|
||||||
|
type AuthUser,
|
||||||
|
} from "./auth"
|
||||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||||
import { createSupermemoryServer } from "./server"
|
import { createSupermemoryServer } from "./server"
|
||||||
import type { ActorContext, ServerEnv } from "./types"
|
import type { ActorContext, ServerEnv } from "./types"
|
||||||
|
|
@ -42,7 +48,7 @@ app.use(
|
||||||
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
|
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
|
||||||
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
|
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
|
||||||
exposeHeaders: ["WWW-Authenticate"],
|
exposeHeaders: ["WWW-Authenticate", "Retry-After"],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -123,6 +129,30 @@ function authInfoFor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthResolution =
|
||||||
|
| { ok: true; user: AuthUser }
|
||||||
|
| { ok: false; reason: "invalid" }
|
||||||
|
| { ok: false; reason: "transient" }
|
||||||
|
|
||||||
|
// Keeps a transient upstream failure distinct from an invalid token.
|
||||||
|
async function resolveAuthUser(
|
||||||
|
token: string,
|
||||||
|
apiUrl: string,
|
||||||
|
mcpResource: string,
|
||||||
|
): Promise<AuthResolution> {
|
||||||
|
try {
|
||||||
|
const user = isApiKey(token)
|
||||||
|
? await validateApiKey(token, apiUrl)
|
||||||
|
: await validateOAuthToken(token, apiUrl, mcpResource)
|
||||||
|
return user ? { ok: true, user } : { ok: false, reason: "invalid" }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TransientAuthError) {
|
||||||
|
return { ok: false, reason: "transient" }
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function unauthorizedResponse(
|
function unauthorizedResponse(
|
||||||
resourceMetadataUrl: string,
|
resourceMetadataUrl: string,
|
||||||
invalidToken = false,
|
invalidToken = false,
|
||||||
|
|
@ -176,8 +206,23 @@ async function handleMcpRequest(
|
||||||
|
|
||||||
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
||||||
|
|
||||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
const resolved = await resolveAuthUser(token, apiUrl, mcpResource)
|
||||||
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
|
if (!resolved.ok && resolved.reason === "transient") {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
error: {
|
||||||
|
code: -32001,
|
||||||
|
message:
|
||||||
|
"Authentication backend temporarily unavailable, please retry",
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
},
|
||||||
|
{ status: 503, headers: { "Retry-After": "5" } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!resolved.ok) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||||
|
const authUser = resolved.user
|
||||||
|
|
||||||
const actor: ActorContext = {
|
const actor: ActorContext = {
|
||||||
userId: authUser.userId,
|
userId: authUser.userId,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export function register(deps: ToolDeps) {
|
||||||
description: "Fetch documents with memories for graph display",
|
description: "Fetch documents with memories for graph display",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
containerTag: optionalContainerTagSchema,
|
containerTag: optionalContainerTagSchema,
|
||||||
page: z.number().optional().default(1),
|
page: z.number().int().min(1).max(10_000).optional().default(1),
|
||||||
limit: z.number().optional().default(200),
|
limit: z.number().int().min(1).max(1_000).optional().default(200),
|
||||||
}),
|
}),
|
||||||
outputSchema: documentsApiResponseSchema,
|
outputSchema: documentsApiResponseSchema,
|
||||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function register(deps: ToolDeps) {
|
||||||
{
|
{
|
||||||
title: "Get Document",
|
title: "Get Document",
|
||||||
description:
|
description:
|
||||||
"Read one stored document by ID, including its summary and available content. Use listDocuments in the intended space to discover document IDs.",
|
"Read one stored document by ID from any space you can access, including its summary and available content. Use listDocuments to discover document IDs.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
outputSchema: getDocumentOutputSchema,
|
outputSchema: getDocumentOutputSchema,
|
||||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ export function register(deps: ToolDeps) {
|
||||||
description:
|
description:
|
||||||
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
|
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
prefill: z.string().optional().describe("Optional content to prefill"),
|
prefill: z
|
||||||
|
.string()
|
||||||
|
.max(200000, "Prefill exceeds maximum length")
|
||||||
|
.optional()
|
||||||
|
.describe("Optional content to prefill"),
|
||||||
}),
|
}),
|
||||||
outputSchema: saveViewSchema,
|
outputSchema: saveViewSchema,
|
||||||
_meta: appToolMeta(),
|
_meta: appToolMeta(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import {
|
import {
|
||||||
containerTagAccessSchema,
|
containerTagAccessSchema,
|
||||||
|
memoriesListSchema,
|
||||||
paginationSchema,
|
paginationSchema,
|
||||||
sessionScopeSchema,
|
sessionScopeSchema,
|
||||||
} from "../../shared/types"
|
} from "../../shared/types"
|
||||||
|
|
@ -42,33 +43,6 @@ const documentSummarySchema = z.object({
|
||||||
summary: z.string().nullable(),
|
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({
|
export const addMemoryOutputSchema = z.object({
|
||||||
action: z.enum(["save", "forget"]),
|
action: z.enum(["save", "forget"]),
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
|
|
@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({
|
||||||
|
|
||||||
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
||||||
|
|
||||||
export const listMemoriesOutputSchema = z.object({
|
// Reuse the shared schema so the tool's output contract stays identical to what
|
||||||
memoryEntries: z.array(memoryEntryOutputSchema),
|
// the client parses — the two can't drift.
|
||||||
pagination: paginationSchema,
|
export const listMemoriesOutputSchema = memoriesListSchema
|
||||||
})
|
|
||||||
|
|
||||||
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
||||||
|
|
||||||
|
|
@ -149,7 +122,6 @@ export const whoAmIOutputSchema = z.object({
|
||||||
version: z.string().optional(),
|
version: z.string().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
sessionId: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>
|
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export function register(deps: ToolDeps) {
|
||||||
deps.getActiveContainerTag(),
|
deps.getActiveContainerTag(),
|
||||||
])
|
])
|
||||||
const client = deps.getClientInfo(context)
|
const client = deps.getClientInfo(context)
|
||||||
const sessionId = context.sessionId
|
|
||||||
const structuredContent: WhoAmIOutput = {
|
const structuredContent: WhoAmIOutput = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
...(session.user.email ? { email: session.user.email } : {}),
|
...(session.user.email ? { email: session.user.email } : {}),
|
||||||
|
|
@ -34,7 +33,6 @@ export function register(deps: ToolDeps) {
|
||||||
: null,
|
: null,
|
||||||
...(session.scope ? { scope: session.scope } : {}),
|
...(session.scope ? { scope: session.scope } : {}),
|
||||||
...(client ? { client } : {}),
|
...(client ? { client } : {}),
|
||||||
...(sessionId ? { sessionId } : {}),
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
content: [textContent(JSON.stringify(structuredContent))],
|
content: [textContent(JSON.stringify(structuredContent))],
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
org: z.looseObject({ id: z.string().min(1) }).optional(),
|
||||||
role: z.string().optional(),
|
role: z.string().optional(),
|
||||||
accessType: z.enum(["full", "restricted"]).optional(),
|
accessType: z.enum(["full", "restricted"]).optional(),
|
||||||
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||||
|
|
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
|
||||||
|
|
||||||
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
|
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`.
|
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
// 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.
|
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,18 @@ const extractContent = (memory: SearchResult) => {
|
||||||
return "No content available"
|
return "No content available"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadata.url comes from ingested content, so only http(s) reaches the OS opener.
|
||||||
const extractUrl = (memory: SearchResult) => {
|
const extractUrl = (memory: SearchResult) => {
|
||||||
if (memory.metadata?.url && typeof memory.metadata.url === "string") {
|
if (memory.metadata?.url && typeof memory.metadata.url === "string") {
|
||||||
return memory.metadata.url
|
const url = memory.metadata.url
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
apps/sdk-playground/.env.example
Normal file
11
apps/sdk-playground/.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Local development only. Use disposable development credentials, not production keys.
|
||||||
|
SUPERMEMORY_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
SUPERMEMORY_BASE_URL=
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
MODEL_NAME=gpt-4o-mini
|
||||||
|
SDK_PLAYGROUND_PYTHON_URL=http://127.0.0.1:8792
|
||||||
|
SDK_PLAYGROUND_PYTHON_PORT=8792
|
||||||
|
# Leave unset unless a trusted non-local development hostname must use env keys.
|
||||||
|
# SDK_PLAYGROUND_ALLOW_ENV_KEYS=true
|
||||||
7
apps/sdk-playground/.gitignore
vendored
Normal file
7
apps/sdk-playground/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.next
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
node_modules
|
||||||
|
python/.venv
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
94
apps/sdk-playground/README.md
Normal file
94
apps/sdk-playground/README.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# SDK Agent Playground
|
||||||
|
|
||||||
|
Chat with a **real agent** and switch which Supermemory SDK integration powers it.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This is a local, single-user development tool. It makes real API calls, stores
|
||||||
|
> browser-entered keys only in memory unless you opt into tab-scoped
|
||||||
|
> `sessionStorage`, and exposes tools that can permanently delete documents. Use
|
||||||
|
> disposable development credentials and a test container; do not deploy it or
|
||||||
|
> point it at production data.
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
| SDK | Style | What happens |
|
||||||
|
|-----|-------|----------------|
|
||||||
|
| AI SDK + middleware | automatic | `withSupermemory` injects context + saves chat |
|
||||||
|
| OpenAI + middleware | automatic | same, via OpenAI client wrapper |
|
||||||
|
| AI SDK + tools | explicit | model calls 7 memory tools via `generateText` |
|
||||||
|
| OpenAI + tools | explicit | OpenAI function-calling loop |
|
||||||
|
| `@supermemory/ai-sdk` | explicit | re-export of tools/ai-sdk |
|
||||||
|
| Python OpenAI middleware | automatic | `with_supermemory` |
|
||||||
|
| Python OpenAI tools | explicit | `SupermemoryTools` loop |
|
||||||
|
| Python supermemory direct | manual | `profile()` + OpenAI + `add()` |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Prerequisites: Bun 1.3.6, Python 3.11+, and
|
||||||
|
[`uv`](https://docs.astral.sh/uv/). Portless is required only for the HTTPS
|
||||||
|
development hostname; the direct localhost commands below work without it.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
|
||||||
|
cp apps/sdk-playground/.env.example apps/sdk-playground/.env.local
|
||||||
|
# Required:
|
||||||
|
# SUPERMEMORY_API_KEY=...
|
||||||
|
# OPENAI_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
The playground scripts build `@supermemory/tools` first and
|
||||||
|
`@supermemory/ai-sdk` second before starting, type-checking, or building the
|
||||||
|
Next.js app. Development mode also watches both workspace packages.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd apps/sdk-playground dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens:
|
||||||
|
|
||||||
|
- **Chat UI** — https://sdk.dev.supermemory.ai via Portless
|
||||||
|
- **Next.js server** — http://127.0.0.1:3005
|
||||||
|
- **Python server** — http://127.0.0.1:8792
|
||||||
|
|
||||||
|
To run without Portless, use two terminals:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd apps/sdk-playground dev:next
|
||||||
|
bun run --cwd apps/sdk-playground dev:python
|
||||||
|
```
|
||||||
|
|
||||||
|
For a production-mode local smoke check, build first and then start. `start`
|
||||||
|
runs both the built Next.js app and the Python server, and remains intended for
|
||||||
|
local use only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --cwd apps/sdk-playground build
|
||||||
|
bun run --cwd apps/sdk-playground start
|
||||||
|
```
|
||||||
|
|
||||||
|
Try:
|
||||||
|
|
||||||
|
- "Remember that I prefer oat milk in coffee"
|
||||||
|
- "What do you know about my drink preferences?"
|
||||||
|
- "Forget that I like tea" (tools mode)
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| Variable | Required |
|
||||||
|
|----------|----------|
|
||||||
|
| `SUPERMEMORY_API_KEY` | yes |
|
||||||
|
| `OPENAI_API_KEY` | yes |
|
||||||
|
| `SUPERMEMORY_BASE_URL` | optional |
|
||||||
|
| `MODEL_NAME` | optional (default `gpt-4o-mini`) |
|
||||||
|
| `SDK_PLAYGROUND_PYTHON_URL` | optional (default `http://127.0.0.1:8792`) |
|
||||||
|
| `SDK_PLAYGROUND_PYTHON_PORT` | optional (default `8792`) |
|
||||||
|
| `SDK_PLAYGROUND_ALLOW_ENV_KEYS` | optional; set `true` only when a trusted non-local hostname must use server env keys |
|
||||||
|
|
||||||
|
Server environment keys are exposed to the playground routes only on loopback
|
||||||
|
hosts and `sdk.dev.supermemory.ai` by default. Browser-provided keys remain
|
||||||
|
request-scoped and are never copied into process-global environment variables.
|
||||||
7
apps/sdk-playground/next.config.ts
Normal file
7
apps/sdk-playground/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: ["@supermemory/tools", "@supermemory/ai-sdk"],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
39
apps/sdk-playground/package.json
Normal file
39
apps/sdk-playground/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "sdk-playground",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"portless": { "name": "sdk.dev.supermemory", "script": "dev:app" },
|
||||||
|
"scripts": {
|
||||||
|
"dev": "portless",
|
||||||
|
"dev:app": "bun run build:dependencies && concurrently -k -n tools,ai-sdk,next,py -c yellow,magenta,blue,green \"bun run --cwd ../../packages/tools dev\" \"bun run --cwd ../../packages/ai-sdk dev\" \"next dev --hostname 127.0.0.1 --port ${PORT:-3005}\" \"bun run dev:python\"",
|
||||||
|
"dev:next": "bun run build:dependencies && next dev --hostname 127.0.0.1 --port ${PORT:-3005}",
|
||||||
|
"dev:python": "cd python && uv run server.py",
|
||||||
|
"build:dependencies": "bun run --cwd ../../packages/tools build && bun run --cwd ../../packages/ai-sdk build",
|
||||||
|
"build:app": "next build",
|
||||||
|
"build": "bun run build:dependencies && bun run build:app",
|
||||||
|
"start": "concurrently -k -n next,py -c blue,green \"next start --hostname 127.0.0.1 --port ${PORT:-3005}\" \"bun run dev:python\"",
|
||||||
|
"typegen": "next typegen",
|
||||||
|
"check-types:app": "bun run typegen && tsc --noEmit --incremental false",
|
||||||
|
"check-types": "bun run build:dependencies && bun run check-types:app"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^2.0.22",
|
||||||
|
"@supermemory/ai-sdk": "workspace:*",
|
||||||
|
"@supermemory/tools": "workspace:*",
|
||||||
|
"ai": "^5.0.113",
|
||||||
|
"next": "16.0.7",
|
||||||
|
"openai": "^4.104.0",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"supermemory": "^4.25.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/sdk-playground/postcss.config.mjs
Normal file
7
apps/sdk-playground/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
16
apps/sdk-playground/python/pyproject.toml
Normal file
16
apps/sdk-playground/python/pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[project]
|
||||||
|
name = "sdk-playground-python"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
|
"supermemory>=3.50.0",
|
||||||
|
"openai>=1.102.0",
|
||||||
|
"supermemory-openai-sdk[async]",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
supermemory-openai-sdk = { path = "../../../packages/openai-sdk-python", editable = true }
|
||||||
885
apps/sdk-playground/python/server.py
Normal file
885
apps/sdk-playground/python/server.py
Normal file
|
|
@ -0,0 +1,885 @@
|
||||||
|
"""HTTP server for Python SDK chat integrations in the playground."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Literal, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, Header, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field, SecretStr, model_validator
|
||||||
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
|
||||||
|
_root = Path(__file__).resolve().parent
|
||||||
|
load_dotenv(_root / ".env")
|
||||||
|
load_dotenv(_root.parent / ".env.local")
|
||||||
|
load_dotenv(_root.parent / ".env")
|
||||||
|
|
||||||
|
DEFAULT_SUPERMEMORY_BASE_URL = "https://api.supermemory.ai"
|
||||||
|
HTTP_TIMEOUT_SECONDS = 60.0
|
||||||
|
CHAT_TIMEOUT_SECONDS = 115.0
|
||||||
|
CONTEXT_DEBUG_TIMEOUT_SECONDS = 10.0
|
||||||
|
DIRECT_SAVE_TIMEOUT_SECONDS = 10.0
|
||||||
|
MAX_OUTPUT_TOKENS = 2_048
|
||||||
|
MAX_MESSAGE_LENGTH = 20_000
|
||||||
|
MAX_MESSAGES = 64
|
||||||
|
MAX_TOTAL_MESSAGE_LENGTH = 100_000
|
||||||
|
MAX_API_KEY_LENGTH = 1_024
|
||||||
|
MAX_CONTAINER_TAG_LENGTH = 100
|
||||||
|
MAX_CONVERSATION_ID_LENGTH = 242
|
||||||
|
CONTAINER_TAG_PATTERN = r"^[a-zA-Z0-9_:-]+$"
|
||||||
|
|
||||||
|
TOOLS_SYSTEM_PROMPT = """You are a helpful assistant with Supermemory long-term memory.
|
||||||
|
|
||||||
|
You have tools to manage memory. Use them proactively:
|
||||||
|
- search_memories: hybrid recall — search before answering whenever user-specific context could help (do not wait to be asked)
|
||||||
|
- get_profile: broad static/dynamic user context at conversation start or when you need a wide overview
|
||||||
|
- add_memory: store a new generalizable fact
|
||||||
|
- document_list / document_add / document_delete: manage source documents
|
||||||
|
- memory_forget: soft-delete one profile fact (not whole documents)
|
||||||
|
|
||||||
|
Before answering questions about the user, their preferences, or past context, search memories or get profile first. When the user asks you to remember something, use add_memory."""
|
||||||
|
|
||||||
|
app = FastAPI(title="SDK Playground Python Chat")
|
||||||
|
app.add_middleware(
|
||||||
|
TrustedHostMiddleware,
|
||||||
|
allowed_hosts=["127.0.0.1", "localhost"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: Literal["user", "assistant", "system"]
|
||||||
|
content: str = Field(max_length=MAX_MESSAGE_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class MiddlewareConfig(BaseModel):
|
||||||
|
addMemory: Literal["always", "never"] = "always"
|
||||||
|
verbose: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundInputError(ValueError):
|
||||||
|
"""A request value is missing after transport-level validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class SupermemoryApiKeys(BaseModel):
|
||||||
|
supermemoryApiKey: SecretStr = Field(max_length=MAX_API_KEY_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeys(SupermemoryApiKeys):
|
||||||
|
openaiApiKey: SecretStr = Field(max_length=MAX_API_KEY_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
sdkId: Literal[
|
||||||
|
"py-openai-middleware",
|
||||||
|
"py-openai-tools",
|
||||||
|
"py-supermemory-direct",
|
||||||
|
]
|
||||||
|
messages: list[ChatMessage] = Field(min_length=1, max_length=MAX_MESSAGES)
|
||||||
|
containerTag: str = Field(
|
||||||
|
default="sdk-playground",
|
||||||
|
min_length=1,
|
||||||
|
max_length=MAX_CONTAINER_TAG_LENGTH,
|
||||||
|
pattern=CONTAINER_TAG_PATTERN,
|
||||||
|
)
|
||||||
|
conversationId: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=MAX_CONVERSATION_ID_LENGTH,
|
||||||
|
)
|
||||||
|
memoryMode: Optional[Literal["profile", "query", "full"]] = "full"
|
||||||
|
middlewareConfig: Optional[MiddlewareConfig] = None
|
||||||
|
apiKeys: Optional[ApiKeys] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_user_message(self) -> "ChatRequest":
|
||||||
|
if not any(
|
||||||
|
message.role == "user" and message.content.strip()
|
||||||
|
for message in self.messages
|
||||||
|
):
|
||||||
|
raise ValueError("messages must include a non-empty user message")
|
||||||
|
if (
|
||||||
|
sum(len(message.content) for message in self.messages)
|
||||||
|
> MAX_TOTAL_MESSAGE_LENGTH
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"total message content cannot exceed {MAX_TOTAL_MESSAGE_LENGTH} characters"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ContextRequest(BaseModel):
|
||||||
|
containerTag: str = Field(
|
||||||
|
default="sdk-playground",
|
||||||
|
min_length=1,
|
||||||
|
max_length=MAX_CONTAINER_TAG_LENGTH,
|
||||||
|
pattern=CONTAINER_TAG_PATTERN,
|
||||||
|
)
|
||||||
|
query: Optional[str] = Field(default=None, max_length=MAX_MESSAGE_LENGTH)
|
||||||
|
apiKeys: Optional[SupermemoryApiKeys] = None
|
||||||
|
|
||||||
|
|
||||||
|
def model_name() -> str:
|
||||||
|
return os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||||
|
|
||||||
|
|
||||||
|
def supplied_secret(value: Optional[SecretStr], label: str) -> str:
|
||||||
|
secret = value.get_secret_value().strip() if value else ""
|
||||||
|
if not secret:
|
||||||
|
raise PlaygroundInputError(f"{label} must be supplied with the request")
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_supermemory_key(api_keys: Optional[SupermemoryApiKeys]) -> str:
|
||||||
|
return supplied_secret(
|
||||||
|
api_keys.supermemoryApiKey if api_keys else None,
|
||||||
|
"Supermemory API key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_chat_keys(api_keys: Optional[ApiKeys]) -> tuple[str, str]:
|
||||||
|
return (
|
||||||
|
resolve_supermemory_key(api_keys),
|
||||||
|
supplied_secret(api_keys.openaiApiKey if api_keys else None, "OpenAI API key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def supermemory_base_url() -> str:
|
||||||
|
configured = os.getenv("SUPERMEMORY_BASE_URL", "").strip()
|
||||||
|
base_url = (configured or DEFAULT_SUPERMEMORY_BASE_URL).rstrip("/")
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise RuntimeError("SUPERMEMORY_BASE_URL must be an absolute HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise RuntimeError(
|
||||||
|
"SUPERMEMORY_BASE_URL cannot contain credentials, a query, or a fragment"
|
||||||
|
)
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
|
||||||
|
def public_error(error: Exception, *secrets: str) -> str:
|
||||||
|
message = str(error)
|
||||||
|
for secret in secrets:
|
||||||
|
if secret:
|
||||||
|
message = message.replace(secret, "[redacted]")
|
||||||
|
return message[:1_000]
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_openai_middleware(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
container_tag: str,
|
||||||
|
conversation_id: str,
|
||||||
|
memory_mode: str,
|
||||||
|
middleware_config: MiddlewareConfig,
|
||||||
|
sm_key: str,
|
||||||
|
oai_key: str,
|
||||||
|
) -> str:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from supermemory_openai import OpenAIMiddlewareOptions, with_supermemory
|
||||||
|
|
||||||
|
client = with_supermemory(
|
||||||
|
AsyncOpenAI(
|
||||||
|
api_key=oai_key,
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
max_retries=1,
|
||||||
|
),
|
||||||
|
OpenAIMiddlewareOptions(
|
||||||
|
container_tag=container_tag,
|
||||||
|
custom_id=conversation_id,
|
||||||
|
mode=memory_mode,
|
||||||
|
add_memory=middleware_config.addMemory,
|
||||||
|
verbose=middleware_config.verbose,
|
||||||
|
api_key=sm_key,
|
||||||
|
base_url=supermemory_base_url(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_messages = [m.model_dump() for m in messages]
|
||||||
|
if not any(m.role == "system" for m in messages):
|
||||||
|
openai_messages.insert(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are a helpful assistant with long-term memory about the user."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=model_name(),
|
||||||
|
messages=openai_messages,
|
||||||
|
max_completion_tokens=MAX_OUTPUT_TOKENS,
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_openai_tools(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
container_tag: str,
|
||||||
|
sm_key: str,
|
||||||
|
oai_key: str,
|
||||||
|
) -> tuple[str, list[dict[str, Any]]]:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from supermemory_openai import SupermemoryTools, execute_memory_tool_calls
|
||||||
|
|
||||||
|
openai_client = AsyncOpenAI(
|
||||||
|
api_key=oai_key,
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"base_url": supermemory_base_url(),
|
||||||
|
"container_tags": [container_tag],
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = SupermemoryTools(sm_key, config)
|
||||||
|
tool_defs = tools.get_tool_definitions()
|
||||||
|
trace: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
convo: list[dict[str, Any]] = [
|
||||||
|
{"role": "system", "content": TOOLS_SYSTEM_PROMPT},
|
||||||
|
*[m.model_dump() for m in messages if m.role != "system"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for step in range(8):
|
||||||
|
response = await openai_client.chat.completions.create(
|
||||||
|
model=model_name(),
|
||||||
|
messages=convo,
|
||||||
|
tools=tool_defs,
|
||||||
|
max_completion_tokens=MAX_OUTPUT_TOKENS,
|
||||||
|
)
|
||||||
|
message = response.choices[0].message
|
||||||
|
convo.append(message.model_dump())
|
||||||
|
|
||||||
|
if message.tool_calls:
|
||||||
|
tool_messages = await execute_memory_tool_calls(
|
||||||
|
sm_key,
|
||||||
|
message.tool_calls,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
for i, call in enumerate(message.tool_calls):
|
||||||
|
raw = tool_messages[i]["content"]
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = raw
|
||||||
|
trace.append(
|
||||||
|
{
|
||||||
|
"step": step + 1,
|
||||||
|
"toolName": call.function.name,
|
||||||
|
"args": json.loads(call.function.arguments),
|
||||||
|
"result": parsed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
convo.extend(tool_messages)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return message.content or "", trace
|
||||||
|
|
||||||
|
raise RuntimeError("Tool loop exceeded max steps")
|
||||||
|
|
||||||
|
|
||||||
|
def object_field(value: Any, name: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get(name, default)
|
||||||
|
return getattr(value, name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def list_field(value: Any, name: str) -> list[Any]:
|
||||||
|
result = object_field(value, name, [])
|
||||||
|
return result if isinstance(result, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def extract_profile_context(profile_response: Any) -> dict[str, list[Any]]:
|
||||||
|
profile = object_field(profile_response, "profile", {}) or {}
|
||||||
|
search_results = object_field(profile_response, "search_results", None)
|
||||||
|
if search_results is None and isinstance(profile_response, dict):
|
||||||
|
search_results = profile_response.get("searchResults")
|
||||||
|
|
||||||
|
if isinstance(search_results, list):
|
||||||
|
search_list = search_results
|
||||||
|
else:
|
||||||
|
search_list = list_field(search_results, "results")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"static": list_field(profile, "static"),
|
||||||
|
"dynamic": list_field(profile, "dynamic"),
|
||||||
|
"searchResults": search_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def display_context_item(item: Any) -> str:
|
||||||
|
if hasattr(item, "model_dump"):
|
||||||
|
return json.dumps(item.model_dump(mode="json"), ensure_ascii=False)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
return json.dumps(item, ensure_ascii=False)
|
||||||
|
return str(item)
|
||||||
|
|
||||||
|
|
||||||
|
def direct_conversation_custom_id(conversation_id: str) -> str:
|
||||||
|
readable = re.sub(r"[^A-Za-z0-9._-]+", "-", conversation_id).strip("-._")
|
||||||
|
readable = readable[:40] or "session"
|
||||||
|
digest = hashlib.sha256(conversation_id.encode("utf-8")).hexdigest()[:12]
|
||||||
|
return f"sdk-playground-direct-{readable}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_transcript(messages: list[ChatMessage], assistant_text: str) -> str:
|
||||||
|
transcript = [
|
||||||
|
f"{message.role.capitalize()}: {message.content}"
|
||||||
|
for message in messages
|
||||||
|
if message.role != "system"
|
||||||
|
]
|
||||||
|
transcript.append(f"Assistant: {assistant_text or '(empty response)'}")
|
||||||
|
return "\n\n".join(transcript)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_profile_context(
|
||||||
|
container_tag: str,
|
||||||
|
sm_key: str,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
include: Optional[list[str]] = None,
|
||||||
|
) -> dict[str, list[Any]]:
|
||||||
|
from supermemory import AsyncSupermemory
|
||||||
|
|
||||||
|
client = AsyncSupermemory(
|
||||||
|
api_key=sm_key,
|
||||||
|
base_url=supermemory_base_url(),
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
request: dict[str, Any] = {"container_tag": container_tag}
|
||||||
|
if query:
|
||||||
|
request["q"] = query
|
||||||
|
if include is not None:
|
||||||
|
request["include"] = include
|
||||||
|
profile_response = await client.profile(**request)
|
||||||
|
return extract_profile_context(profile_response)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_supermemory_direct(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
container_tag: str,
|
||||||
|
conversation_id: str,
|
||||||
|
sm_key: str,
|
||||||
|
oai_key: str,
|
||||||
|
) -> tuple[str, str, dict[str, list[Any]]]:
|
||||||
|
"""Manual pattern: profile() for context, then OpenAI, then add() conversation."""
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from supermemory import AsyncSupermemory
|
||||||
|
|
||||||
|
sm_client = AsyncSupermemory(
|
||||||
|
api_key=sm_key,
|
||||||
|
base_url=supermemory_base_url(),
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
openai_client = AsyncOpenAI(
|
||||||
|
api_key=oai_key,
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_messages = [m for m in messages if m.role == "user"]
|
||||||
|
last_user = user_messages[-1].content if user_messages else ""
|
||||||
|
|
||||||
|
profile_response = await sm_client.profile(
|
||||||
|
container_tag=container_tag,
|
||||||
|
**({"q": last_user} if last_user else {}),
|
||||||
|
)
|
||||||
|
profile_context = extract_profile_context(profile_response)
|
||||||
|
context = "\n".join(
|
||||||
|
(
|
||||||
|
"Profile static: "
|
||||||
|
+ ", ".join(map(display_context_item, profile_context["static"])),
|
||||||
|
"Profile dynamic: "
|
||||||
|
+ ", ".join(map(display_context_item, profile_context["dynamic"])),
|
||||||
|
"Relevant search results: "
|
||||||
|
+ ", ".join(map(display_context_item, profile_context["searchResults"])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_messages: list[dict[str, str]] = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": f"You are a helpful assistant. User context:\n{context}",
|
||||||
|
},
|
||||||
|
*[m.model_dump() for m in messages if m.role != "system"],
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await openai_client.chat.completions.create(
|
||||||
|
model=model_name(),
|
||||||
|
messages=openai_messages,
|
||||||
|
max_completion_tokens=MAX_OUTPUT_TOKENS,
|
||||||
|
)
|
||||||
|
assistant_text = response.choices[0].message.content or ""
|
||||||
|
|
||||||
|
custom_id = direct_conversation_custom_id(conversation_id)
|
||||||
|
return assistant_text, custom_id, profile_context
|
||||||
|
|
||||||
|
|
||||||
|
async def save_direct_conversation(
|
||||||
|
messages: list[ChatMessage],
|
||||||
|
assistant_text: str,
|
||||||
|
container_tag: str,
|
||||||
|
custom_id: str,
|
||||||
|
sm_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from supermemory import AsyncSupermemory
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AsyncSupermemory(
|
||||||
|
api_key=sm_key,
|
||||||
|
base_url=supermemory_base_url(),
|
||||||
|
timeout=DIRECT_SAVE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
async with asyncio.timeout(DIRECT_SAVE_TIMEOUT_SECONDS):
|
||||||
|
response = await client.add(
|
||||||
|
content=conversation_transcript(messages, assistant_text),
|
||||||
|
container_tag=container_tag,
|
||||||
|
custom_id=custom_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "conversation_save_accepted",
|
||||||
|
"label": "Full conversation accepted for processing",
|
||||||
|
"detail": {
|
||||||
|
"nonFatal": True,
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"customId": custom_id,
|
||||||
|
"documentId": object_field(response, "id"),
|
||||||
|
"status": object_field(response, "status"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
return {
|
||||||
|
"type": "conversation_save_failed",
|
||||||
|
"label": "Conversation save unavailable",
|
||||||
|
"detail": {
|
||||||
|
"nonFatal": True,
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"customId": custom_id,
|
||||||
|
"error": public_error(error, sm_key),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_container_context(
|
||||||
|
container_tag: str,
|
||||||
|
sm_key: str,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not sm_key:
|
||||||
|
raise RuntimeError("Supermemory API key must be supplied")
|
||||||
|
|
||||||
|
profile_context = await fetch_profile_context(container_tag, sm_key, query)
|
||||||
|
base_url = supermemory_base_url()
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=HTTP_TIMEOUT_SECONDS,
|
||||||
|
follow_redirects=False,
|
||||||
|
) as http:
|
||||||
|
docs_response = await http.post(
|
||||||
|
f"{base_url}/v3/documents/documents",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {sm_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"containerTags": [container_tag],
|
||||||
|
"limit": 25,
|
||||||
|
"sort": "createdAt",
|
||||||
|
"order": "desc",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
docs_response.raise_for_status()
|
||||||
|
docs = docs_response.json()
|
||||||
|
|
||||||
|
raw_documents = docs.get("documents", []) if isinstance(docs, dict) else []
|
||||||
|
|
||||||
|
documents = []
|
||||||
|
for doc in raw_documents:
|
||||||
|
record = doc if isinstance(doc, dict) else getattr(doc, "__dict__", {})
|
||||||
|
memory_entries = (
|
||||||
|
record.get("memoryEntries") or record.get("memory_entries") or []
|
||||||
|
)
|
||||||
|
if not memory_entries and isinstance(record.get("memories"), list):
|
||||||
|
nested = record.get("memories") or []
|
||||||
|
if nested and isinstance(nested[0], dict) and nested[0].get("memory"):
|
||||||
|
memory_entries = nested
|
||||||
|
documents.append(
|
||||||
|
{
|
||||||
|
"id": record.get("id"),
|
||||||
|
"title": record.get("title"),
|
||||||
|
"status": record.get("status"),
|
||||||
|
"customId": record.get("customId") or record.get("custom_id"),
|
||||||
|
"createdAt": record.get("createdAt") or record.get("created_at"),
|
||||||
|
"updatedAt": record.get("updatedAt") or record.get("updated_at"),
|
||||||
|
"summary": record.get("summary"),
|
||||||
|
"memoryEntries": memory_entries,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"query": query,
|
||||||
|
"profile": profile_context,
|
||||||
|
"documents": documents,
|
||||||
|
"pagination": docs.get("pagination") if isinstance(docs, dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_python_sdk_memory_block(
|
||||||
|
memory_mode: str,
|
||||||
|
profile: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, list[str]], str]:
|
||||||
|
from supermemory_openai import convert_profile_to_markdown, deduplicate_memories
|
||||||
|
from supermemory_openai.utils import wrap_memory_context
|
||||||
|
|
||||||
|
deduplicated = deduplicate_memories(
|
||||||
|
static=profile.get("static", []) if memory_mode != "query" else [],
|
||||||
|
dynamic=profile.get("dynamic", []) if memory_mode != "query" else [],
|
||||||
|
search_results=profile.get("searchResults", []),
|
||||||
|
)
|
||||||
|
visible_profile = {
|
||||||
|
"static": deduplicated.static,
|
||||||
|
"dynamic": deduplicated.dynamic,
|
||||||
|
"searchResults": (
|
||||||
|
[] if memory_mode == "profile" else deduplicated.search_results
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
profile_data = ""
|
||||||
|
if memory_mode != "query":
|
||||||
|
profile_data = convert_profile_to_markdown(
|
||||||
|
{
|
||||||
|
"profile": {
|
||||||
|
"static": visible_profile["static"],
|
||||||
|
"dynamic": visible_profile["dynamic"],
|
||||||
|
},
|
||||||
|
"searchResults": {"results": []},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
search_results_memories = ""
|
||||||
|
if memory_mode != "profile" and visible_profile["searchResults"]:
|
||||||
|
search_results_memories = (
|
||||||
|
"Search results for user's recent message: \n"
|
||||||
|
+ "\n".join(f"- {memory}" for memory in visible_profile["searchResults"])
|
||||||
|
)
|
||||||
|
|
||||||
|
memories = f"{profile_data}\n{search_results_memories}".strip()
|
||||||
|
return visible_profile, wrap_memory_context(memories)
|
||||||
|
|
||||||
|
|
||||||
|
def build_middleware_memory_debug(
|
||||||
|
container_tag: str,
|
||||||
|
conversation_id: str,
|
||||||
|
memory_mode: str,
|
||||||
|
last_user_message: str,
|
||||||
|
context: Optional[dict[str, Any]],
|
||||||
|
context_error: Optional[str],
|
||||||
|
middleware_config: MiddlewareConfig,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
debug: list[dict[str, Any]] = []
|
||||||
|
if context is None:
|
||||||
|
debug.append(
|
||||||
|
{
|
||||||
|
"type": "context_debug_unavailable",
|
||||||
|
"label": "Post-response context snapshot unavailable",
|
||||||
|
"detail": {"error": context_error or "Unknown context error"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raw_profile = context["profile"]
|
||||||
|
profile, memory_block = reconstruct_python_sdk_memory_block(
|
||||||
|
memory_mode,
|
||||||
|
raw_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
debug.extend(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"type": "profile_fetch",
|
||||||
|
"label": "Post-response context reconstruction",
|
||||||
|
"detail": {
|
||||||
|
"authoritativeMiddlewareCapture": False,
|
||||||
|
"timing": "after model response",
|
||||||
|
"endpoint": "POST /v4/profile",
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"customId": conversation_id,
|
||||||
|
"memoryMode": memory_mode,
|
||||||
|
"query": context.get("query"),
|
||||||
|
"staticCount": len(profile.get("static", [])),
|
||||||
|
"dynamicCount": len(profile.get("dynamic", [])),
|
||||||
|
"searchResultCount": len(profile.get("searchResults", [])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "context_preview",
|
||||||
|
"label": (
|
||||||
|
"Reconstructed SDK-owned memory block "
|
||||||
|
"(not middleware capture)"
|
||||||
|
),
|
||||||
|
"preview": memory_block,
|
||||||
|
"detail": {
|
||||||
|
"totalFacts": (
|
||||||
|
len(profile.get("static", []))
|
||||||
|
+ len(profile.get("dynamic", []))
|
||||||
|
+ len(profile.get("searchResults", []))
|
||||||
|
),
|
||||||
|
"fullLength": len(memory_block),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
save_detail = {
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"customId": f"conversation:{conversation_id}",
|
||||||
|
"addMemory": middleware_config.addMemory,
|
||||||
|
"verbose": middleware_config.verbose,
|
||||||
|
}
|
||||||
|
if middleware_config.addMemory == "always" and last_user_message.strip():
|
||||||
|
debug.append(
|
||||||
|
{
|
||||||
|
"type": "conversation_save_queued",
|
||||||
|
"label": "Conversation save queued by middleware",
|
||||||
|
"detail": save_detail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
debug.append(
|
||||||
|
{
|
||||||
|
"type": "conversation_save_skipped",
|
||||||
|
"label": "Conversation save disabled",
|
||||||
|
"detail": save_detail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return debug
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_context_for_debug(
|
||||||
|
container_tag: str,
|
||||||
|
query: Optional[str],
|
||||||
|
sm_key: str,
|
||||||
|
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(CONTEXT_DEBUG_TIMEOUT_SECONDS):
|
||||||
|
profile = await fetch_profile_context(
|
||||||
|
container_tag,
|
||||||
|
sm_key,
|
||||||
|
query,
|
||||||
|
include=["static", "dynamic"],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"containerTag": container_tag,
|
||||||
|
"query": query,
|
||||||
|
"profile": profile,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return None, public_error(error, sm_key)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/context")
|
||||||
|
async def context_get(
|
||||||
|
containerTag: Annotated[
|
||||||
|
str,
|
||||||
|
Query(
|
||||||
|
min_length=1,
|
||||||
|
max_length=MAX_CONTAINER_TAG_LENGTH,
|
||||||
|
pattern=CONTAINER_TAG_PATTERN,
|
||||||
|
),
|
||||||
|
] = "sdk-playground",
|
||||||
|
query: Annotated[Optional[str], Query(max_length=MAX_MESSAGE_LENGTH)] = None,
|
||||||
|
x_supermemory_api_key: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Header(alias="X-Supermemory-API-Key"),
|
||||||
|
] = None,
|
||||||
|
):
|
||||||
|
sm_key = ""
|
||||||
|
try:
|
||||||
|
sm_key = supplied_secret(
|
||||||
|
SecretStr(x_supermemory_api_key) if x_supermemory_api_key else None,
|
||||||
|
"X-Supermemory-API-Key header",
|
||||||
|
)
|
||||||
|
async with asyncio.timeout(HTTP_TIMEOUT_SECONDS):
|
||||||
|
ctx = await fetch_container_context(containerTag, sm_key, query)
|
||||||
|
return {"ok": True, "context": ctx}
|
||||||
|
except Exception as error:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=(
|
||||||
|
504
|
||||||
|
if isinstance(error, TimeoutError)
|
||||||
|
else 400 if isinstance(error, PlaygroundInputError) else 500
|
||||||
|
),
|
||||||
|
content={"ok": False, "error": public_error(error, sm_key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/context")
|
||||||
|
async def context_post(req: ContextRequest):
|
||||||
|
sm_key = ""
|
||||||
|
try:
|
||||||
|
sm_key = resolve_supermemory_key(req.apiKeys)
|
||||||
|
async with asyncio.timeout(HTTP_TIMEOUT_SECONDS):
|
||||||
|
ctx = await fetch_container_context(req.containerTag, sm_key, req.query)
|
||||||
|
return {"ok": True, "context": ctx}
|
||||||
|
except Exception as error:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=(
|
||||||
|
504
|
||||||
|
if isinstance(error, TimeoutError)
|
||||||
|
else 400 if isinstance(error, PlaygroundInputError) else 500
|
||||||
|
),
|
||||||
|
content={"ok": False, "error": public_error(error, sm_key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"playground": "sdk-playground",
|
||||||
|
"requiresRequestKeys": True,
|
||||||
|
"model": model_name(),
|
||||||
|
"sdks": [
|
||||||
|
"py-openai-middleware",
|
||||||
|
"py-openai-tools",
|
||||||
|
"py-supermemory-direct",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chat")
|
||||||
|
async def chat(req: ChatRequest):
|
||||||
|
started = time.time()
|
||||||
|
sm_key = ""
|
||||||
|
oai_key = ""
|
||||||
|
try:
|
||||||
|
sm_key, oai_key = resolve_chat_keys(req.apiKeys)
|
||||||
|
tool_trace: list[dict[str, Any]] = []
|
||||||
|
memory_debug: list[dict[str, Any]] = []
|
||||||
|
middleware_debug: Optional[tuple[MiddlewareConfig, str, Optional[str]]] = None
|
||||||
|
direct_debug: Optional[tuple[str, dict[str, list[Any]], str]] = None
|
||||||
|
async with asyncio.timeout(CHAT_TIMEOUT_SECONDS):
|
||||||
|
if req.sdkId == "py-openai-middleware":
|
||||||
|
middleware_config = req.middlewareConfig or MiddlewareConfig()
|
||||||
|
text = await chat_openai_middleware(
|
||||||
|
req.messages,
|
||||||
|
req.containerTag,
|
||||||
|
req.conversationId,
|
||||||
|
req.memoryMode or "full",
|
||||||
|
middleware_config,
|
||||||
|
sm_key,
|
||||||
|
oai_key,
|
||||||
|
)
|
||||||
|
last_user = next(
|
||||||
|
(m.content for m in reversed(req.messages) if m.role == "user"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
query = last_user if req.memoryMode != "profile" else None
|
||||||
|
middleware_debug = (middleware_config, last_user, query)
|
||||||
|
elif req.sdkId == "py-openai-tools":
|
||||||
|
text, tool_trace = await chat_openai_tools(
|
||||||
|
req.messages, req.containerTag, sm_key, oai_key
|
||||||
|
)
|
||||||
|
elif req.sdkId == "py-supermemory-direct":
|
||||||
|
text, custom_id, profile_context = await chat_supermemory_direct(
|
||||||
|
req.messages,
|
||||||
|
req.containerTag,
|
||||||
|
req.conversationId,
|
||||||
|
sm_key,
|
||||||
|
oai_key,
|
||||||
|
)
|
||||||
|
last_user = next(
|
||||||
|
(m.content for m in reversed(req.messages) if m.role == "user"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
direct_debug = (custom_id, profile_context, last_user)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported Python SDK: {req.sdkId}")
|
||||||
|
|
||||||
|
if middleware_debug is not None:
|
||||||
|
middleware_config, last_user, query = middleware_debug
|
||||||
|
ctx, context_error = await fetch_context_for_debug(
|
||||||
|
req.containerTag,
|
||||||
|
query,
|
||||||
|
sm_key,
|
||||||
|
)
|
||||||
|
memory_debug = build_middleware_memory_debug(
|
||||||
|
req.containerTag,
|
||||||
|
req.conversationId,
|
||||||
|
req.memoryMode or "full",
|
||||||
|
last_user,
|
||||||
|
ctx,
|
||||||
|
context_error,
|
||||||
|
middleware_config,
|
||||||
|
)
|
||||||
|
elif direct_debug is not None:
|
||||||
|
custom_id, profile_context, last_user = direct_debug
|
||||||
|
save_debug = await save_direct_conversation(
|
||||||
|
req.messages,
|
||||||
|
text,
|
||||||
|
req.containerTag,
|
||||||
|
custom_id,
|
||||||
|
sm_key,
|
||||||
|
)
|
||||||
|
memory_debug = [
|
||||||
|
{
|
||||||
|
"type": "manual_profile",
|
||||||
|
"label": "Profile context used for this response",
|
||||||
|
"detail": {
|
||||||
|
"containerTag": req.containerTag,
|
||||||
|
"query": last_user,
|
||||||
|
"staticCount": len(profile_context["static"]),
|
||||||
|
"dynamicCount": len(profile_context["dynamic"]),
|
||||||
|
"searchResultCount": len(profile_context["searchResults"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
save_debug,
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"sdkId": req.sdkId,
|
||||||
|
"message": {"role": "assistant", "content": text},
|
||||||
|
"toolTrace": tool_trace,
|
||||||
|
"memoryDebug": memory_debug,
|
||||||
|
"durationMs": int((time.time() - started) * 1000),
|
||||||
|
}
|
||||||
|
except TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=504,
|
||||||
|
content={
|
||||||
|
"ok": False,
|
||||||
|
"sdkId": req.sdkId,
|
||||||
|
"error": f"Python chat timed out after {int(CHAT_TIMEOUT_SECONDS)} seconds",
|
||||||
|
"durationMs": int((time.time() - started) * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400 if isinstance(error, PlaygroundInputError) else 500,
|
||||||
|
content={
|
||||||
|
"ok": False,
|
||||||
|
"sdkId": req.sdkId,
|
||||||
|
"error": public_error(error, sm_key, oai_key),
|
||||||
|
"durationMs": int((time.time() - started) * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
port = int(os.getenv("SDK_PLAYGROUND_PYTHON_PORT", "8792"))
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
|
||||||
1557
apps/sdk-playground/python/uv.lock
generated
Normal file
1557
apps/sdk-playground/python/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
167
apps/sdk-playground/src/app/api/chat/route.ts
Normal file
167
apps/sdk-playground/src/app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { CHAT_SDK_REGISTRY, PYTHON_SERVER_URL } from "@/lib/sdk-registry"
|
||||||
|
import {
|
||||||
|
resolveApiKeys,
|
||||||
|
resolveOpenAiApiKey,
|
||||||
|
resolveSupermemoryApiKey,
|
||||||
|
} from "@/lib/api-keys"
|
||||||
|
import {
|
||||||
|
PlaygroundChatTimeoutError,
|
||||||
|
runTypeScriptChat,
|
||||||
|
} from "@/lib/chat-handlers"
|
||||||
|
import {
|
||||||
|
PlaygroundRequestError,
|
||||||
|
assertTrustedBrowserRequest,
|
||||||
|
mayUseEnvironmentKeys,
|
||||||
|
parseApiKeys,
|
||||||
|
parseContainerTag,
|
||||||
|
parseConversationId,
|
||||||
|
parseIdentifier,
|
||||||
|
parseMemoryMode,
|
||||||
|
parseMessages,
|
||||||
|
parseMiddlewareConfig,
|
||||||
|
readJsonObject,
|
||||||
|
} from "@/lib/request-validation"
|
||||||
|
|
||||||
|
const PYTHON_HEALTH_TIMEOUT_MS = 2_000
|
||||||
|
// Python reserves 115s for the model/tool path and up to 10s for nonfatal debug.
|
||||||
|
const PYTHON_CHAT_TIMEOUT_MS = 130_000
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
let pythonOk = false
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PYTHON_SERVER_URL}/health`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(PYTHON_HEALTH_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
pythonOk = data.playground === "sdk-playground"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pythonOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowEnvironment = mayUseEnvironmentKeys(request)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
sdks: CHAT_SDK_REGISTRY,
|
||||||
|
hasSupermemoryKey: Boolean(
|
||||||
|
resolveSupermemoryApiKey(null, { allowEnvironment }),
|
||||||
|
),
|
||||||
|
hasOpenAiKey: Boolean(resolveOpenAiApiKey(null, { allowEnvironment })),
|
||||||
|
pythonUrl: PYTHON_SERVER_URL,
|
||||||
|
model: process.env.MODEL_NAME ?? "gpt-4o-mini",
|
||||||
|
pythonOk,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const started = Date.now()
|
||||||
|
try {
|
||||||
|
assertTrustedBrowserRequest(req)
|
||||||
|
const body = await readJsonObject(req)
|
||||||
|
const sdkId = parseIdentifier(body.sdkId, "sdkId")
|
||||||
|
const messages = parseMessages(body.messages)
|
||||||
|
const containerTag = parseContainerTag(body.containerTag)
|
||||||
|
const conversationId = parseConversationId(body.conversationId)
|
||||||
|
const memoryMode = parseMemoryMode(body.memoryMode)
|
||||||
|
const middlewareConfig = parseMiddlewareConfig(body.middlewareConfig)
|
||||||
|
const apiKeys = resolveApiKeys(parseApiKeys(body.apiKeys), {
|
||||||
|
allowEnvironment: mayUseEnvironmentKeys(req),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiKeys) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Supermemory and OpenAI API keys are required — enter them in the dashboard or set env vars.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdk = CHAT_SDK_REGISTRY.find((s) => s.id === sdkId)
|
||||||
|
if (!sdk?.available) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `SDK not available: ${sdkId}` },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdk.language === "python") {
|
||||||
|
const res = await fetch(`${PYTHON_SERVER_URL}/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sdkId,
|
||||||
|
messages,
|
||||||
|
containerTag,
|
||||||
|
conversationId,
|
||||||
|
memoryMode,
|
||||||
|
middlewareConfig,
|
||||||
|
apiKeys,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(PYTHON_CHAT_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok && !data.error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: `Python server error (${res.status})`,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
},
|
||||||
|
{ status: res.status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
},
|
||||||
|
{ status: res.ok ? 200 : res.status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runTypeScriptChat(
|
||||||
|
{
|
||||||
|
sdkId,
|
||||||
|
messages,
|
||||||
|
containerTag,
|
||||||
|
conversationId,
|
||||||
|
memoryMode,
|
||||||
|
middlewareConfig,
|
||||||
|
apiKeys,
|
||||||
|
containerTags: [containerTag],
|
||||||
|
},
|
||||||
|
apiKeys,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
sdkId,
|
||||||
|
message: { role: "assistant", content: result.text },
|
||||||
|
toolTrace: result.toolTrace,
|
||||||
|
memoryDebug: result.memoryDebug,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const status =
|
||||||
|
error instanceof PlaygroundRequestError
|
||||||
|
? error.status
|
||||||
|
: error instanceof PlaygroundChatTimeoutError ||
|
||||||
|
(error instanceof Error && error.name === "TimeoutError")
|
||||||
|
? 504
|
||||||
|
: 500
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/sdk-playground/src/app/api/context/route.ts
Normal file
90
apps/sdk-playground/src/app/api/context/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { resolveSupermemoryApiKey } from "@/lib/api-keys"
|
||||||
|
import { fetchContainerContext } from "@/lib/context-api"
|
||||||
|
import {
|
||||||
|
PlaygroundRequestError,
|
||||||
|
assertTrustedBrowserRequest,
|
||||||
|
mayUseEnvironmentKeys,
|
||||||
|
parseApiKeys,
|
||||||
|
parseContainerTag,
|
||||||
|
parseOptionalText,
|
||||||
|
readJsonObject,
|
||||||
|
} from "@/lib/request-validation"
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
assertTrustedBrowserRequest(req)
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const containerTag = parseContainerTag(searchParams.get("containerTag"))
|
||||||
|
const query = parseOptionalText(searchParams.get("query"), "query")
|
||||||
|
const supermemoryApiKey = resolveSupermemoryApiKey(null, {
|
||||||
|
allowEnvironment: mayUseEnvironmentKeys(req),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supermemoryApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Supermemory API key is required — enter it in the dashboard or set a local env var.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await fetchContainerContext(
|
||||||
|
containerTag,
|
||||||
|
query,
|
||||||
|
supermemoryApiKey,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ ok: true, context })
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof PlaygroundRequestError ? error.status : 500
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
assertTrustedBrowserRequest(req)
|
||||||
|
const body = await readJsonObject(req)
|
||||||
|
const containerTag = parseContainerTag(body.containerTag)
|
||||||
|
const query = parseOptionalText(body.query, "query")
|
||||||
|
const supermemoryApiKey = resolveSupermemoryApiKey(
|
||||||
|
parseApiKeys(body.apiKeys),
|
||||||
|
{ allowEnvironment: mayUseEnvironmentKeys(req) },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!supermemoryApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Supermemory API key is required — enter it in the dashboard.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await fetchContainerContext(
|
||||||
|
containerTag,
|
||||||
|
query,
|
||||||
|
supermemoryApiKey,
|
||||||
|
)
|
||||||
|
return NextResponse.json({ ok: true, context })
|
||||||
|
} catch (error) {
|
||||||
|
const status = error instanceof PlaygroundRequestError ? error.status : 500
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/sdk-playground/src/app/globals.css
Normal file
18
apps/sdk-playground/src/app/globals.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
19
apps/sdk-playground/src/app/layout.tsx
Normal file
19
apps/sdk-playground/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import "./globals.css"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Supermemory SDK Playground",
|
||||||
|
description: "Switch and test Supermemory SDKs across languages",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
623
apps/sdk-playground/src/app/page.tsx
Normal file
623
apps/sdk-playground/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { ApiKeysPanel } from "@/components/ApiKeysPanel"
|
||||||
|
import { ContextPanel } from "@/components/ContextPanel"
|
||||||
|
import { ToolsReferencePanel } from "@/components/ToolsReferencePanel"
|
||||||
|
import type { MemoryDebugEntry } from "@/lib/context-api"
|
||||||
|
import {
|
||||||
|
DEFAULT_MIDDLEWARE_CONFIG,
|
||||||
|
type MiddlewareRuntimeConfig,
|
||||||
|
} from "@/lib/middleware-config"
|
||||||
|
import {
|
||||||
|
CHAT_SDK_REGISTRY,
|
||||||
|
type ChatSdkDefinition,
|
||||||
|
type ToolTraceEntry,
|
||||||
|
} from "@/lib/sdk-registry"
|
||||||
|
|
||||||
|
type UserOrAssistantMessage = {
|
||||||
|
kind: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolMessage = {
|
||||||
|
kind: "tool"
|
||||||
|
entry: ToolTraceEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type DebugMessage = {
|
||||||
|
kind: "debug"
|
||||||
|
entry: MemoryDebugEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayMessage = UserOrAssistantMessage | ToolMessage | DebugMessage
|
||||||
|
|
||||||
|
export default function AgentPlaygroundPage() {
|
||||||
|
const [sdks, setSdks] = useState(CHAT_SDK_REGISTRY)
|
||||||
|
const [hasSupermemoryKey, setHasSupermemoryKey] = useState(false)
|
||||||
|
const [hasOpenAiKey, setHasOpenAiKey] = useState(false)
|
||||||
|
const [pythonOk, setPythonOk] = useState(false)
|
||||||
|
const [model, setModel] = useState("gpt-4o-mini")
|
||||||
|
const [pythonUrl, setPythonUrl] = useState("http://127.0.0.1:8792")
|
||||||
|
|
||||||
|
const [supermemoryApiKey, setSupermemoryApiKey] = useState("")
|
||||||
|
const [openaiApiKey, setOpenaiApiKey] = useState("")
|
||||||
|
const apiKeys = useMemo(
|
||||||
|
() => ({ supermemoryApiKey, openaiApiKey }),
|
||||||
|
[supermemoryApiKey, openaiApiKey],
|
||||||
|
)
|
||||||
|
const supermemoryKeyReady =
|
||||||
|
supermemoryApiKey.trim().length > 0 || hasSupermemoryKey
|
||||||
|
const openAiKeyReady = openaiApiKey.trim().length > 0 || hasOpenAiKey
|
||||||
|
const keysReady = supermemoryKeyReady && openAiKeyReady
|
||||||
|
|
||||||
|
const [sdkId, setSdkId] = useState("ts-ai-sdk-middleware")
|
||||||
|
const [containerTag, setContainerTag] = useState("sdk-playground")
|
||||||
|
const [memoryMode, setMemoryMode] = useState<"profile" | "query" | "full">(
|
||||||
|
"full",
|
||||||
|
)
|
||||||
|
const [conversationId, setConversationId] = useState("")
|
||||||
|
const [middlewareConfig, setMiddlewareConfig] =
|
||||||
|
useState<MiddlewareRuntimeConfig>(DEFAULT_MIDDLEWARE_CONFIG)
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<DisplayMessage[]>([])
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [contextRefreshKey, setContextRefreshKey] = useState(0)
|
||||||
|
const [leftPanel, setLeftPanel] = useState<"sdks" | "tools">("sdks")
|
||||||
|
|
||||||
|
const lastUserMessage = useMemo(() => {
|
||||||
|
const users = messages.filter(
|
||||||
|
(m): m is UserOrAssistantMessage => m.kind === "user",
|
||||||
|
)
|
||||||
|
return users.at(-1)?.content ?? ""
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const selectedSdk = useMemo(
|
||||||
|
() => sdks.find((s) => s.id === sdkId),
|
||||||
|
[sdks, sdkId],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConversationId((current) => current || crypto.randomUUID())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshMeta = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chat")
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.sdks) setSdks(data.sdks)
|
||||||
|
setHasSupermemoryKey(Boolean(data.hasSupermemoryKey))
|
||||||
|
setHasOpenAiKey(Boolean(data.hasOpenAiKey))
|
||||||
|
setPythonOk(Boolean(data.pythonOk))
|
||||||
|
if (data.pythonUrl) setPythonUrl(data.pythonUrl)
|
||||||
|
if (data.model) setModel(data.model)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMeta()
|
||||||
|
}, [refreshMeta])
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!input.trim() || loading || !selectedSdk?.available || !keysReady)
|
||||||
|
return
|
||||||
|
const activeConversationId = conversationId.trim() || crypto.randomUUID()
|
||||||
|
if (!conversationId.trim()) setConversationId(activeConversationId)
|
||||||
|
|
||||||
|
const userMessage: UserOrAssistantMessage = {
|
||||||
|
kind: "user",
|
||||||
|
content: input.trim(),
|
||||||
|
}
|
||||||
|
const chatHistory = messages
|
||||||
|
.filter(
|
||||||
|
(m): m is UserOrAssistantMessage =>
|
||||||
|
m.kind === "user" || m.kind === "assistant",
|
||||||
|
)
|
||||||
|
.map((m) => ({ role: m.kind, content: m.content }))
|
||||||
|
const nextMessages = [...messages, userMessage]
|
||||||
|
setMessages(nextMessages)
|
||||||
|
setInput("")
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sdkId,
|
||||||
|
messages: [
|
||||||
|
...chatHistory,
|
||||||
|
{ role: "user", content: userMessage.content },
|
||||||
|
],
|
||||||
|
containerTag,
|
||||||
|
conversationId: activeConversationId,
|
||||||
|
memoryMode:
|
||||||
|
selectedSdk.mode === "middleware" ? memoryMode : undefined,
|
||||||
|
middlewareConfig:
|
||||||
|
selectedSdk.mode === "middleware" ? middlewareConfig : undefined,
|
||||||
|
apiKeys,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.ok) {
|
||||||
|
throw new Error(data.error ?? "Chat failed")
|
||||||
|
}
|
||||||
|
const content = data.message?.content ?? ""
|
||||||
|
const toolTrace = (data.toolTrace ?? []) as ToolTraceEntry[]
|
||||||
|
const memoryDebug = (data.memoryDebug ?? []) as MemoryDebugEntry[]
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
...memoryDebug.map((entry) => ({ kind: "debug" as const, entry })),
|
||||||
|
...toolTrace.map((entry) => ({ kind: "tool" as const, entry })),
|
||||||
|
{ kind: "assistant", content },
|
||||||
|
])
|
||||||
|
setContextRefreshKey((k) => k + 1)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsSdks = sdks.filter((s) => s.language === "typescript")
|
||||||
|
const pySdks = sdks.filter((s) => s.language === "python")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-screen max-w-[1400px] flex-col p-4 md:p-5">
|
||||||
|
<header className="mb-4 shrink-0 space-y-3 border-b border-zinc-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
Supermemory Agent Playground
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Talk to a real agent. Switch the underlying SDK integration in the
|
||||||
|
sidebar — middleware auto-injects memory; tools let the model call
|
||||||
|
memory operations explicitly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<Status ok={supermemoryKeyReady} label="Supermemory key" />
|
||||||
|
<Status ok={openAiKeyReady} label="OpenAI key" />
|
||||||
|
<Status
|
||||||
|
ok={pythonOk}
|
||||||
|
label={`Python ${pythonUrl.replace("http://", "")}`}
|
||||||
|
/>
|
||||||
|
<span className="rounded-full border border-zinc-700 px-3 py-1 text-zinc-400">
|
||||||
|
model: {model}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ApiKeysPanel
|
||||||
|
supermemoryApiKey={supermemoryApiKey}
|
||||||
|
openaiApiKey={openaiApiKey}
|
||||||
|
hasSupermemoryEnvKey={hasSupermemoryKey}
|
||||||
|
hasOpenAiEnvKey={hasOpenAiKey}
|
||||||
|
onSupermemoryChange={setSupermemoryApiKey}
|
||||||
|
onOpenAiChange={setOpenaiApiKey}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 gap-3 lg:gap-4">
|
||||||
|
<aside className="hidden w-60 shrink-0 min-h-0 lg:flex lg:flex-col">
|
||||||
|
<div className="mb-2 flex gap-1 shrink-0">
|
||||||
|
<SidebarTab
|
||||||
|
active={leftPanel === "sdks"}
|
||||||
|
onClick={() => setLeftPanel("sdks")}
|
||||||
|
>
|
||||||
|
SDKs
|
||||||
|
</SidebarTab>
|
||||||
|
<SidebarTab
|
||||||
|
active={leftPanel === "tools"}
|
||||||
|
onClick={() => setLeftPanel("tools")}
|
||||||
|
>
|
||||||
|
Tools
|
||||||
|
</SidebarTab>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{leftPanel === "sdks" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section title="TypeScript">
|
||||||
|
<SdkButtons
|
||||||
|
sdks={tsSdks}
|
||||||
|
selected={sdkId}
|
||||||
|
onSelect={setSdkId}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Python">
|
||||||
|
<SdkButtons
|
||||||
|
sdks={pySdks}
|
||||||
|
selected={sdkId}
|
||||||
|
onSelect={setSdkId}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ToolsReferencePanel />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
|
<div className="flex gap-1 shrink-0 lg:hidden">
|
||||||
|
<SidebarTab
|
||||||
|
active={leftPanel === "sdks"}
|
||||||
|
onClick={() => setLeftPanel("sdks")}
|
||||||
|
>
|
||||||
|
Chat
|
||||||
|
</SidebarTab>
|
||||||
|
<SidebarTab
|
||||||
|
active={leftPanel === "tools"}
|
||||||
|
onClick={() => setLeftPanel("tools")}
|
||||||
|
>
|
||||||
|
Tool reference
|
||||||
|
</SidebarTab>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leftPanel === "tools" && (
|
||||||
|
<div className="max-h-72 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/20 p-3 lg:hidden">
|
||||||
|
<ToolsReferencePanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leftPanel === "sdks" && (
|
||||||
|
<div className="flex flex-wrap items-end gap-3 md:hidden">
|
||||||
|
<label className="flex-1 space-y-1">
|
||||||
|
<span className="text-xs text-zinc-500">SDK</span>
|
||||||
|
<select
|
||||||
|
value={sdkId}
|
||||||
|
onChange={(e) => setSdkId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{sdks.map((s) => (
|
||||||
|
<option key={s.id} value={s.id} disabled={!s.available}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedSdk && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-sm">
|
||||||
|
<div className="font-medium">{selectedSdk.label}</div>
|
||||||
|
<div className="text-zinc-400">{selectedSdk.description}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">
|
||||||
|
{selectedSdk.package} ·{" "}
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
{selectedSdk.mode === "middleware"
|
||||||
|
? "automatic memory"
|
||||||
|
: selectedSdk.mode === "tools"
|
||||||
|
? "explicit tools"
|
||||||
|
: "manual profile + save"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-zinc-500">Container tag</span>
|
||||||
|
<input
|
||||||
|
value={containerTag}
|
||||||
|
onChange={(e) => setContainerTag(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm w-40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-zinc-500">customId (session)</span>
|
||||||
|
<input
|
||||||
|
value={conversationId}
|
||||||
|
onChange={(e) => setConversationId(e.target.value)}
|
||||||
|
maxLength={242}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-mono w-52"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{selectedSdk?.mode === "middleware" && (
|
||||||
|
<>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-zinc-500">Memory mode</span>
|
||||||
|
<select
|
||||||
|
value={memoryMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMemoryMode(
|
||||||
|
e.target.value as "profile" | "query" | "full",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="profile">profile</option>
|
||||||
|
<option value="query">query</option>
|
||||||
|
<option value="full">full</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-zinc-500">addMemory</span>
|
||||||
|
<select
|
||||||
|
value={middlewareConfig.addMemory}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMiddlewareConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
addMemory: e.target.value as "always" | "never",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="always">always</option>
|
||||||
|
<option value="never">never</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={middlewareConfig.verbose}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMiddlewareConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
verbose: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
verbose
|
||||||
|
</label>
|
||||||
|
{selectedSdk.id === "ts-ai-sdk-middleware" && (
|
||||||
|
<>
|
||||||
|
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={middlewareConfig.includeToolCalls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMiddlewareConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
includeToolCalls: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
includeToolCalls
|
||||||
|
</label>
|
||||||
|
<label className="flex items-end gap-2 pb-1.5 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={middlewareConfig.skipMemoryOnError}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMiddlewareConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
skipMemoryOnError: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
skipMemoryOnError
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900/30 p-4 space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="text-center text-sm text-zinc-500 py-8">
|
||||||
|
Say hi — try "Remember that I prefer oat milk" or
|
||||||
|
"What do you know about me?"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
if (m.kind === "debug") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex justify-start">
|
||||||
|
<div className="max-w-[92%] rounded-lg border border-violet-900/50 bg-violet-950/25 px-3 py-2 text-xs">
|
||||||
|
<div className="font-medium text-violet-300 mb-1">
|
||||||
|
Debug · {m.entry.label}
|
||||||
|
</div>
|
||||||
|
{m.entry.detail && (
|
||||||
|
<pre className="font-mono text-violet-100/70 whitespace-pre-wrap break-all mb-2">
|
||||||
|
{JSON.stringify(m.entry.detail, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{m.entry.preview && (
|
||||||
|
<pre className="font-mono text-zinc-300 whitespace-pre-wrap break-all border-t border-violet-900/40 pt-2 mt-1">
|
||||||
|
{m.entry.preview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (m.kind === "tool") {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex justify-start">
|
||||||
|
<div className="max-w-[90%] rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs font-mono text-amber-100/90">
|
||||||
|
<div className="font-sans text-amber-400 font-medium mb-1">
|
||||||
|
Tool · step {m.entry.step} · {m.entry.toolName}
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-400">args</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-all mb-2">
|
||||||
|
{JSON.stringify(m.entry.args, null, 2)}
|
||||||
|
</pre>
|
||||||
|
{m.entry.result !== undefined && (
|
||||||
|
<>
|
||||||
|
<div className="text-zinc-400">result</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(m.entry.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
m.kind === "user"
|
||||||
|
? "flex justify-end"
|
||||||
|
: "flex justify-start"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm leading-relaxed ${
|
||||||
|
m.kind === "user"
|
||||||
|
? "bg-emerald-700 text-white"
|
||||||
|
: "bg-zinc-800 text-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-zinc-500 animate-pulse">
|
||||||
|
Thinking…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-900 bg-red-950/50 px-3 py-2 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading || !selectedSdk?.available || !keysReady}
|
||||||
|
placeholder={
|
||||||
|
keysReady
|
||||||
|
? "Message the agent…"
|
||||||
|
: "Enter API keys above to chat…"
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-600/50 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={send}
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
!input.trim() ||
|
||||||
|
!selectedSdk?.available ||
|
||||||
|
!keysReady
|
||||||
|
}
|
||||||
|
className="rounded-lg bg-emerald-600 px-5 py-3 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="hidden w-80 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900/20 p-3 xl:flex xl:w-96 xl:flex-col min-h-0">
|
||||||
|
<ContextPanel
|
||||||
|
containerTag={containerTag}
|
||||||
|
lastUserMessage={lastUserMessage}
|
||||||
|
refreshKey={contextRefreshKey}
|
||||||
|
supermemoryApiKey={supermemoryApiKey}
|
||||||
|
supermemoryKeyReady={supermemoryKeyReady}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Status({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full border px-3 py-1 ${
|
||||||
|
ok
|
||||||
|
? "border-emerald-800 text-emerald-400"
|
||||||
|
: "border-zinc-700 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-500">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SdkButtons({
|
||||||
|
sdks,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
sdks: ChatSdkDefinition[]
|
||||||
|
selected: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sdks.map((sdk) => (
|
||||||
|
<li key={sdk.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!sdk.available}
|
||||||
|
onClick={() => onSelect(sdk.id)}
|
||||||
|
className={`w-full rounded-md px-2 py-2 text-left text-sm transition-colors ${
|
||||||
|
selected === sdk.id
|
||||||
|
? "bg-zinc-800 text-white"
|
||||||
|
: "text-zinc-400 hover:bg-zinc-900 hover:text-zinc-200"
|
||||||
|
} ${!sdk.available ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<div>{sdk.label}</div>
|
||||||
|
<div className="text-[10px] text-zinc-500 capitalize">
|
||||||
|
{sdk.mode}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTab({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-zinc-800 text-white"
|
||||||
|
: "text-zinc-500 hover:bg-zinc-900 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
apps/sdk-playground/src/components/ApiKeysPanel.tsx
Normal file
140
apps/sdk-playground/src/components/ApiKeysPanel.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import {
|
||||||
|
clearStoredApiKeys,
|
||||||
|
readStoredApiKeys,
|
||||||
|
storeApiKeys,
|
||||||
|
} from "@/lib/api-keys"
|
||||||
|
|
||||||
|
export function ApiKeysPanel({
|
||||||
|
supermemoryApiKey,
|
||||||
|
openaiApiKey,
|
||||||
|
hasSupermemoryEnvKey,
|
||||||
|
hasOpenAiEnvKey,
|
||||||
|
onSupermemoryChange,
|
||||||
|
onOpenAiChange,
|
||||||
|
}: {
|
||||||
|
supermemoryApiKey: string
|
||||||
|
openaiApiKey: string
|
||||||
|
hasSupermemoryEnvKey: boolean
|
||||||
|
hasOpenAiEnvKey: boolean
|
||||||
|
onSupermemoryChange: (value: string) => void
|
||||||
|
onOpenAiChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [storageInitialized, setStorageInitialized] = useState(false)
|
||||||
|
const [rememberKeys, setRememberKeys] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readStoredApiKeys()
|
||||||
|
const hasStoredKeys = Boolean(
|
||||||
|
stored.supermemoryApiKey || stored.openaiApiKey,
|
||||||
|
)
|
||||||
|
if (stored.supermemoryApiKey) onSupermemoryChange(stored.supermemoryApiKey)
|
||||||
|
if (stored.openaiApiKey) onOpenAiChange(stored.openaiApiKey)
|
||||||
|
setRememberKeys(hasStoredKeys)
|
||||||
|
setStorageInitialized(true)
|
||||||
|
}, [onSupermemoryChange, onOpenAiChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageInitialized) return
|
||||||
|
if (rememberKeys) {
|
||||||
|
storeApiKeys({ supermemoryApiKey, openaiApiKey })
|
||||||
|
} else {
|
||||||
|
clearStoredApiKeys()
|
||||||
|
}
|
||||||
|
}, [storageInitialized, rememberKeys, supermemoryApiKey, openaiApiKey])
|
||||||
|
|
||||||
|
const supermemoryReady =
|
||||||
|
supermemoryApiKey.trim().length > 0 || hasSupermemoryEnvKey
|
||||||
|
const openAiReady = openaiApiKey.trim().length > 0 || hasOpenAiEnvKey
|
||||||
|
const ready = supermemoryReady && openAiReady
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-zinc-200">API keys</span>
|
||||||
|
<span className="flex items-center gap-2 text-xs">
|
||||||
|
<span className={ready ? "text-emerald-400" : "text-amber-400"}>
|
||||||
|
{ready ? "configured" : "required for chat"}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600">{open ? "−" : "+"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="space-y-3 border-t border-zinc-800 px-3 pb-3 pt-2">
|
||||||
|
<p className="rounded border border-amber-900/60 bg-amber-950/30 px-2 py-1.5 text-[11px] leading-snug text-amber-200/80">
|
||||||
|
Use disposable development or test keys only—never production
|
||||||
|
credentials. Keys stay in this page only unless you explicitly
|
||||||
|
enable tab-scoped storage below. Server env vars remain available as
|
||||||
|
fallbacks.
|
||||||
|
</p>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="flex items-center justify-between gap-2 text-xs text-zinc-500">
|
||||||
|
<span>Supermemory API key</span>
|
||||||
|
{hasSupermemoryEnvKey && !supermemoryApiKey.trim() && (
|
||||||
|
<span className="text-emerald-500">using server env</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={supermemoryApiKey}
|
||||||
|
onChange={(e) => onSupermemoryChange(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
hasSupermemoryEnvKey ? "Optional browser override" : "sm_…"
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="flex items-center justify-between gap-2 text-xs text-zinc-500">
|
||||||
|
<span>OpenAI API key</span>
|
||||||
|
{hasOpenAiEnvKey && !openaiApiKey.trim() && (
|
||||||
|
<span className="text-emerald-500">using server env</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={openaiApiKey}
|
||||||
|
onChange={(e) => onOpenAiChange(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
hasOpenAiEnvKey ? "Optional browser override" : "sk-…"
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-[11px] leading-snug text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberKeys}
|
||||||
|
onChange={(event) => setRememberKeys(event.target.checked)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Remember these keys for this tab using sessionStorage. They are
|
||||||
|
cleared when the tab closes; only enable this on a trusted
|
||||||
|
profile.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSupermemoryChange("")
|
||||||
|
onOpenAiChange("")
|
||||||
|
setRememberKeys(false)
|
||||||
|
clearStoredApiKeys()
|
||||||
|
}}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Clear entered keys
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
350
apps/sdk-playground/src/components/ContextPanel.tsx
Normal file
350
apps/sdk-playground/src/components/ContextPanel.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import type { ContainerContext } from "@/lib/context-api"
|
||||||
|
|
||||||
|
type ContextDocument = ContainerContext["documents"][number]
|
||||||
|
|
||||||
|
export function ContextPanel({
|
||||||
|
containerTag,
|
||||||
|
lastUserMessage,
|
||||||
|
refreshKey,
|
||||||
|
supermemoryApiKey,
|
||||||
|
supermemoryKeyReady,
|
||||||
|
}: {
|
||||||
|
containerTag: string
|
||||||
|
lastUserMessage?: string
|
||||||
|
refreshKey: number
|
||||||
|
supermemoryApiKey: string
|
||||||
|
supermemoryKeyReady: boolean
|
||||||
|
}) {
|
||||||
|
const [context, setContext] = useState<ContainerContext | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [useQuery, setUseQuery] = useState(false)
|
||||||
|
const [selectedDocKey, setSelectedDocKey] = useState<string | null>(null)
|
||||||
|
const activeRequest = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!supermemoryKeyReady) {
|
||||||
|
setError("Enter a Supermemory API key or set SUPERMEMORY_API_KEY")
|
||||||
|
setContext(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalizedContainerTag = containerTag.trim()
|
||||||
|
if (!normalizedContainerTag) {
|
||||||
|
setError("Enter a container tag to load context")
|
||||||
|
setContext(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRequest.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
activeRequest.current = controller
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/context", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
containerTag: normalizedContainerTag,
|
||||||
|
...(useQuery && lastUserMessage ? { query: lastUserMessage } : {}),
|
||||||
|
...(supermemoryApiKey.trim()
|
||||||
|
? {
|
||||||
|
apiKeys: {
|
||||||
|
supermemoryApiKey: supermemoryApiKey.trim(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.ok) throw new Error(data.error ?? "Failed to load context")
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
setContext(data.context)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
setContext(null)
|
||||||
|
} finally {
|
||||||
|
if (activeRequest.current === controller) {
|
||||||
|
activeRequest.current = null
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
containerTag,
|
||||||
|
lastUserMessage,
|
||||||
|
useQuery,
|
||||||
|
supermemoryApiKey,
|
||||||
|
supermemoryKeyReady,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// The counter changes after a successful chat and explicitly refreshes context.
|
||||||
|
void refreshKey
|
||||||
|
if (!supermemoryKeyReady || !containerTag.trim()) {
|
||||||
|
activeRequest.current?.abort()
|
||||||
|
activeRequest.current = null
|
||||||
|
setLoading(false)
|
||||||
|
setContext(null)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void load()
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
activeRequest.current?.abort()
|
||||||
|
}
|
||||||
|
}, [load, refreshKey, supermemoryKeyReady, containerTag])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// A different container must not retain the previous document selection.
|
||||||
|
void containerTag
|
||||||
|
setSelectedDocKey(null)
|
||||||
|
}, [containerTag])
|
||||||
|
|
||||||
|
const selectedDoc =
|
||||||
|
context?.documents.find((doc) => documentKey(doc) === selectedDocKey) ??
|
||||||
|
null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col gap-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">
|
||||||
|
Container context
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={!supermemoryKeyReady || !containerTag.trim()}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-200 disabled:cursor-not-allowed disabled:text-zinc-700"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useQuery}
|
||||||
|
onChange={(e) => setUseQuery(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Profile with last message as query
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{loading && <p className="text-xs text-zinc-500">Loading…</p>}
|
||||||
|
{!supermemoryKeyReady && (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Enter a Supermemory key or set SUPERMEMORY_API_KEY to load context.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{context && (
|
||||||
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-medium text-zinc-400">
|
||||||
|
Profile · {context.profile.static.length} static ·{" "}
|
||||||
|
{context.profile.dynamic.length} dynamic ·{" "}
|
||||||
|
{context.profile.searchResults.length} search
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MemoryList title="Static" items={context.profile.static} />
|
||||||
|
<MemoryList title="Dynamic" items={context.profile.dynamic} />
|
||||||
|
{context.profile.searchResults.length > 0 && (
|
||||||
|
<MemoryList
|
||||||
|
title="Search results"
|
||||||
|
items={context.profile.searchResults}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0">
|
||||||
|
<h3 className="mb-2 text-xs font-medium text-zinc-400">
|
||||||
|
Documents / sessions ({context.documents.length})
|
||||||
|
</h3>
|
||||||
|
{context.documents.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-500">No documents yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 gap-2">
|
||||||
|
<ul className="min-w-0 flex-1 space-y-2">
|
||||||
|
{context.documents.map((doc) => {
|
||||||
|
const key = documentKey(doc)
|
||||||
|
const isSelected = key === selectedDocKey
|
||||||
|
const memoryCount = doc.memoryEntries?.length ?? 0
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedDocKey(isSelected ? null : key)
|
||||||
|
}
|
||||||
|
className={`w-full rounded border p-2 text-left text-xs transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "border-emerald-700/60 bg-emerald-950/30"
|
||||||
|
: "border-zinc-800 bg-zinc-900/50 hover:border-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 font-mono text-zinc-300 truncate">
|
||||||
|
{doc.id ?? "—"}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums ${
|
||||||
|
memoryCount > 0
|
||||||
|
? "bg-emerald-900/50 text-emerald-300"
|
||||||
|
: "bg-zinc-800 text-zinc-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{memoryCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-500 truncate">
|
||||||
|
{doc.title ?? "untitled"}
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-600">
|
||||||
|
{doc.customId
|
||||||
|
? `session: ${doc.customId}`
|
||||||
|
: "no customId"}
|
||||||
|
{doc.status ? ` · ${doc.status}` : ""}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{selectedDoc && (
|
||||||
|
<div className="min-w-0 flex-1 border-l border-zinc-800 pl-2">
|
||||||
|
<DocumentMemoriesPanel doc={selectedDoc} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{context.documents.length > 0 && !selectedDoc && (
|
||||||
|
<p className="mt-2 text-[10px] text-zinc-600">
|
||||||
|
Click a document to view its memories
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentMemoriesPanel({ doc }: { doc: ContextDocument }) {
|
||||||
|
const entries = doc.memoryEntries ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-zinc-600">
|
||||||
|
Document memories ({entries.length})
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">
|
||||||
|
{doc.title ?? "untitled"}
|
||||||
|
</div>
|
||||||
|
{doc.summary && (
|
||||||
|
<p className="text-[11px] leading-snug text-zinc-500 line-clamp-3">
|
||||||
|
{doc.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-xs text-zinc-600">No memories on this document</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<li
|
||||||
|
key={memoryEntryKey(entry, i)}
|
||||||
|
className="rounded border border-zinc-800 bg-zinc-900/60 p-2"
|
||||||
|
>
|
||||||
|
<MemoryEntryCard entry={entry} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemoryEntryCard({ entry }: { entry: unknown }) {
|
||||||
|
const record =
|
||||||
|
entry && typeof entry === "object"
|
||||||
|
? (entry as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const memoryText = formatMemoryItem(entry)
|
||||||
|
const id = record?.id as string | undefined
|
||||||
|
const version = record?.version as number | undefined
|
||||||
|
const isForgotten = Boolean(record?.isForgotten)
|
||||||
|
const isStatic = Boolean(record?.isStatic)
|
||||||
|
const forgetAfter = record?.forgetAfter as string | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{memoryText && (
|
||||||
|
<p className="text-xs leading-snug text-zinc-300">{memoryText}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-1 text-[10px] text-zinc-600">
|
||||||
|
{id && <span className="font-mono truncate max-w-full">{id}</span>}
|
||||||
|
{version != null && <span>v{version}</span>}
|
||||||
|
{isStatic && <span className="text-sky-500">static</span>}
|
||||||
|
{isForgotten && <span className="text-amber-500">forgotten</span>}
|
||||||
|
{forgetAfter && !isForgotten && (
|
||||||
|
<span className="text-orange-500">expires</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentKey(doc: ContextDocument): string {
|
||||||
|
return doc.id ?? doc.customId ?? doc.title ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryEntryKey(entry: unknown, index: number): string {
|
||||||
|
if (entry && typeof entry === "object" && "id" in entry) {
|
||||||
|
return String((entry as { id: unknown }).id)
|
||||||
|
}
|
||||||
|
return `memory-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemoryList({ title, items }: { title: string; items: unknown[] }) {
|
||||||
|
if (!items.length) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-zinc-600 mb-1">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{items.slice(0, 12).map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded bg-zinc-900/60 px-2 py-1 text-xs text-zinc-300 leading-snug"
|
||||||
|
>
|
||||||
|
{formatMemoryItem(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemoryItem(item: unknown): string {
|
||||||
|
if (typeof item === "string") return item
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
if (typeof record.memory === "string") return record.memory
|
||||||
|
if (typeof record.content === "string") return record.content
|
||||||
|
if (typeof record.chunk === "string") return record.chunk
|
||||||
|
}
|
||||||
|
return JSON.stringify(item)
|
||||||
|
}
|
||||||
122
apps/sdk-playground/src/components/ToolsReferencePanel.tsx
Normal file
122
apps/sdk-playground/src/components/ToolsReferencePanel.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { TOOL_CATALOG, type CatalogTool } from "@/lib/tools-catalog"
|
||||||
|
|
||||||
|
export function ToolsReferencePanel() {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>("documentAdd")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wider text-zinc-500">
|
||||||
|
Tool reference
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[10px] leading-snug text-zinc-600">
|
||||||
|
Canonical descriptions from{" "}
|
||||||
|
<code className="text-zinc-500">@supermemory/tools</code> — what the
|
||||||
|
model sees in tools mode.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||||
|
{TOOL_CATALOG.map((tool) => (
|
||||||
|
<ToolCard
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
expanded={expandedId === tool.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((id) => (id === tool.id ? null : tool.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCard({
|
||||||
|
tool,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
tool: CatalogTool
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="rounded border border-zinc-800 bg-zinc-900/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="w-full px-2 py-2 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-mono text-xs text-emerald-400/90">
|
||||||
|
{tool.id}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-zinc-600">
|
||||||
|
py: {tool.pythonName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10px] text-zinc-600">
|
||||||
|
{expanded ? "−" : "+"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!expanded && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-zinc-500">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-zinc-800 px-2 pb-2 pt-1 space-y-2">
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-300">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
{tool.parameters.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-zinc-600 mb-1">
|
||||||
|
Parameters
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{tool.parameters.map((param) => (
|
||||||
|
<li
|
||||||
|
key={param.name}
|
||||||
|
className="rounded bg-zinc-950/60 px-2 py-1"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<span className="font-mono text-[10px] text-sky-400/90">
|
||||||
|
{param.name}
|
||||||
|
</span>
|
||||||
|
{param.pythonName && param.pythonName !== param.name && (
|
||||||
|
<span className="font-mono text-[10px] text-zinc-600">
|
||||||
|
/ {param.pythonName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!param.pythonName && (
|
||||||
|
<span className="text-[9px] text-sky-500/80">
|
||||||
|
TypeScript only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{param.required && (
|
||||||
|
<span className="text-[9px] text-amber-500/80">
|
||||||
|
required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[10px] leading-snug text-zinc-500">
|
||||||
|
{param.description}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
apps/sdk-playground/src/lib/api-keys.ts
Normal file
67
apps/sdk-playground/src/lib/api-keys.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
export interface PlaygroundApiKeys {
|
||||||
|
supermemoryApiKey: string
|
||||||
|
openaiApiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_KEYS_STORAGE_KEY = "sdk-playground-api-keys"
|
||||||
|
|
||||||
|
interface ResolveApiKeyOptions {
|
||||||
|
allowEnvironment?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSupermemoryApiKey(
|
||||||
|
input?: Partial<PlaygroundApiKeys> | null,
|
||||||
|
options: ResolveApiKeyOptions = {},
|
||||||
|
): string | null {
|
||||||
|
const provided = input?.supermemoryApiKey?.trim()
|
||||||
|
if (provided) return provided
|
||||||
|
if (options.allowEnvironment === false) return null
|
||||||
|
return process.env.SUPERMEMORY_API_KEY?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOpenAiApiKey(
|
||||||
|
input?: Partial<PlaygroundApiKeys> | null,
|
||||||
|
options: ResolveApiKeyOptions = {},
|
||||||
|
): string | null {
|
||||||
|
const provided = input?.openaiApiKey?.trim()
|
||||||
|
if (provided) return provided
|
||||||
|
if (options.allowEnvironment === false) return null
|
||||||
|
return process.env.OPENAI_API_KEY?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveApiKeys(
|
||||||
|
input?: Partial<PlaygroundApiKeys> | null,
|
||||||
|
options: ResolveApiKeyOptions = {},
|
||||||
|
): PlaygroundApiKeys | null {
|
||||||
|
const supermemoryApiKey = resolveSupermemoryApiKey(input, options)
|
||||||
|
const openaiApiKey = resolveOpenAiApiKey(input, options)
|
||||||
|
|
||||||
|
if (!supermemoryApiKey || !openaiApiKey) return null
|
||||||
|
|
||||||
|
return { supermemoryApiKey, openaiApiKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredApiKeys(): Partial<PlaygroundApiKeys> {
|
||||||
|
if (typeof window === "undefined") return {}
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(API_KEYS_STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PlaygroundApiKeys>
|
||||||
|
return {
|
||||||
|
supermemoryApiKey: parsed.supermemoryApiKey ?? "",
|
||||||
|
openaiApiKey: parsed.openaiApiKey ?? "",
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeApiKeys(keys: Partial<PlaygroundApiKeys>) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
sessionStorage.setItem(API_KEYS_STORAGE_KEY, JSON.stringify(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredApiKeys() {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
sessionStorage.removeItem(API_KEYS_STORAGE_KEY)
|
||||||
|
}
|
||||||
438
apps/sdk-playground/src/lib/chat-handlers.ts
Normal file
438
apps/sdk-playground/src/lib/chat-handlers.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
|
import { generateText, stepCountIs, type ModelMessage } from "ai"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { supermemoryTools as aiSdkPackageTools } from "@supermemory/ai-sdk"
|
||||||
|
import { withSupermemory as withSupermemoryAiSdk } from "@supermemory/tools/ai-sdk"
|
||||||
|
import {
|
||||||
|
createToolCallsExecutor,
|
||||||
|
getToolDefinitions,
|
||||||
|
withSupermemory as withSupermemoryOpenAi,
|
||||||
|
} from "@supermemory/tools/openai"
|
||||||
|
import { supermemoryTools as aiSdkTools } from "@supermemory/tools/ai-sdk"
|
||||||
|
import type { SupermemoryToolsConfig } from "@supermemory/tools"
|
||||||
|
import type { PlaygroundApiKeys } from "./api-keys"
|
||||||
|
import {
|
||||||
|
buildMiddlewareMemoryDebug,
|
||||||
|
type MemoryDebugEntry,
|
||||||
|
} from "./context-api"
|
||||||
|
import {
|
||||||
|
normalizeMiddlewareConfig,
|
||||||
|
type MiddlewareRuntimeConfig,
|
||||||
|
} from "./middleware-config"
|
||||||
|
import {
|
||||||
|
TOOLS_SYSTEM_PROMPT,
|
||||||
|
getChatSdk,
|
||||||
|
type ToolTraceEntry,
|
||||||
|
} from "./sdk-registry"
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: "user" | "assistant" | "system"
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatResult {
|
||||||
|
text: string
|
||||||
|
toolTrace: ToolTraceEntry[]
|
||||||
|
memoryDebug: MemoryDebugEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRequest {
|
||||||
|
sdkId: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
containerTag: string
|
||||||
|
conversationId: string
|
||||||
|
memoryMode?: "profile" | "query" | "full"
|
||||||
|
middlewareConfig?: Partial<MiddlewareRuntimeConfig>
|
||||||
|
apiKeys?: Partial<PlaygroundApiKeys>
|
||||||
|
containerTags?: string[]
|
||||||
|
projectId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaygroundChatTimeoutError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PlaygroundChatTimeoutError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_REQUEST_TIMEOUT_MS = 120_000
|
||||||
|
const DEBUG_REQUEST_TIMEOUT_MS = 10_000
|
||||||
|
const MAX_OUTPUT_TOKENS = 2_048
|
||||||
|
|
||||||
|
async function withChatDeadline<T>(
|
||||||
|
operation: (signal: AbortSignal) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const deadline = new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const error = new PlaygroundChatTimeoutError(
|
||||||
|
`TypeScript chat timed out after ${MODEL_REQUEST_TIMEOUT_MS / 1_000} seconds`,
|
||||||
|
)
|
||||||
|
controller.abort(error)
|
||||||
|
reject(error)
|
||||||
|
}, MODEL_REQUEST_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([operation(controller.signal), deadline])
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBestEffortDebug(
|
||||||
|
operation: (signal: AbortSignal) => Promise<MemoryDebugEntry[]>,
|
||||||
|
): Promise<MemoryDebugEntry[]> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const deadline = new Promise<MemoryDebugEntry[]>((resolve) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
type: "debug_error",
|
||||||
|
label: "Post-response context reconstruction timed out",
|
||||||
|
detail: { nonFatal: true },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, DEBUG_REQUEST_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([operation(controller.signal), deadline])
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "debug_error",
|
||||||
|
label: "Post-response context reconstruction unavailable",
|
||||||
|
detail: { nonFatal: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelName(): string {
|
||||||
|
return process.env.MODEL_NAME ?? "gpt-4o-mini"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolsConfig(
|
||||||
|
containerTags?: string[],
|
||||||
|
projectId?: string,
|
||||||
|
): SupermemoryToolsConfig {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||||
|
...(containerTags?.length ? { containerTags } : {}),
|
||||||
|
...(projectId ? { projectId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toModelMessages(messages: ChatMessage[]): ModelMessage[] {
|
||||||
|
return messages.map((m) => ({ role: m.role, content: m.content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpenAiMessages(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
||||||
|
return messages.map((m) => ({ role: m.role, content: m.content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAiSdkToolTrace(
|
||||||
|
steps: Array<{
|
||||||
|
toolCalls: Array<{ toolName: string; input: unknown }>
|
||||||
|
toolResults: Array<{ toolName: string; output: unknown }>
|
||||||
|
}>,
|
||||||
|
): ToolTraceEntry[] {
|
||||||
|
const trace: ToolTraceEntry[] = []
|
||||||
|
for (const [stepIndex, step] of steps.entries()) {
|
||||||
|
for (let i = 0; i < step.toolCalls.length; i++) {
|
||||||
|
const call = step.toolCalls[i]
|
||||||
|
const result = step.toolResults[i]
|
||||||
|
trace.push({
|
||||||
|
step: stepIndex + 1,
|
||||||
|
toolName: call.toolName,
|
||||||
|
args: call.input,
|
||||||
|
result: result?.output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastUserMessage(messages: ChatMessage[]): string {
|
||||||
|
return [...messages].reverse().find((m) => m.role === "user")?.content ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatAiSdkMiddleware(
|
||||||
|
keys: PlaygroundApiKeys,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
containerTag: string,
|
||||||
|
conversationId: string,
|
||||||
|
memoryMode: "profile" | "query" | "full",
|
||||||
|
middlewareConfig: MiddlewareRuntimeConfig,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const openai = createOpenAI({ apiKey: keys.openaiApiKey })
|
||||||
|
const model = withSupermemoryAiSdk(openai(getModelName()), {
|
||||||
|
containerTag,
|
||||||
|
customId: conversationId,
|
||||||
|
apiKey: keys.supermemoryApiKey,
|
||||||
|
mode: memoryMode,
|
||||||
|
addMemory: middlewareConfig.addMemory,
|
||||||
|
verbose: middlewareConfig.verbose,
|
||||||
|
includeToolCalls: middlewareConfig.includeToolCalls,
|
||||||
|
skipMemoryOnError: middlewareConfig.skipMemoryOnError,
|
||||||
|
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
system: "You are a helpful assistant with long-term memory about the user.",
|
||||||
|
messages: toModelMessages(messages.filter((m) => m.role !== "system")),
|
||||||
|
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||||
|
abortSignal: signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { text: result.text, toolTrace: [], memoryDebug: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatOpenAiMiddleware(
|
||||||
|
keys: PlaygroundApiKeys,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
containerTag: string,
|
||||||
|
conversationId: string,
|
||||||
|
memoryMode: "profile" | "query" | "full",
|
||||||
|
middlewareConfig: MiddlewareRuntimeConfig,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: keys.openaiApiKey,
|
||||||
|
timeout: MODEL_REQUEST_TIMEOUT_MS,
|
||||||
|
maxRetries: 1,
|
||||||
|
})
|
||||||
|
const client = withSupermemoryOpenAi(openai, {
|
||||||
|
containerTag,
|
||||||
|
customId: conversationId,
|
||||||
|
apiKey: keys.supermemoryApiKey,
|
||||||
|
mode: memoryMode,
|
||||||
|
addMemory: middlewareConfig.addMemory,
|
||||||
|
verbose: middlewareConfig.verbose,
|
||||||
|
baseUrl: process.env.SUPERMEMORY_BASE_URL,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create(
|
||||||
|
{
|
||||||
|
model: getModelName(),
|
||||||
|
messages: toOpenAiMessages(messages),
|
||||||
|
max_tokens: MAX_OUTPUT_TOKENS,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: response.choices[0]?.message?.content ?? "",
|
||||||
|
toolTrace: [],
|
||||||
|
memoryDebug: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatAiSdkTools(
|
||||||
|
keys: PlaygroundApiKeys,
|
||||||
|
toolsFactory: typeof aiSdkTools,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
containerTags?: string[],
|
||||||
|
projectId?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const openai = createOpenAI({ apiKey: keys.openaiApiKey })
|
||||||
|
const tools = toolsFactory(
|
||||||
|
keys.supermemoryApiKey,
|
||||||
|
getToolsConfig(containerTags, projectId),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model: openai(getModelName()),
|
||||||
|
system: TOOLS_SYSTEM_PROMPT,
|
||||||
|
messages: toModelMessages(messages.filter((m) => m.role !== "system")),
|
||||||
|
tools,
|
||||||
|
stopWhen: stepCountIs(8),
|
||||||
|
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||||
|
abortSignal: signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: result.text,
|
||||||
|
toolTrace: extractAiSdkToolTrace(result.steps),
|
||||||
|
memoryDebug: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatOpenAiTools(
|
||||||
|
keys: PlaygroundApiKeys,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
containerTags?: string[],
|
||||||
|
projectId?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: keys.openaiApiKey,
|
||||||
|
timeout: MODEL_REQUEST_TIMEOUT_MS,
|
||||||
|
maxRetries: 1,
|
||||||
|
})
|
||||||
|
const config = getToolsConfig(containerTags, projectId)
|
||||||
|
const executeToolCalls = createToolCallsExecutor(
|
||||||
|
keys.supermemoryApiKey,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
const toolDefs = getToolDefinitions()
|
||||||
|
const trace: ToolTraceEntry[] = []
|
||||||
|
|
||||||
|
const convo: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||||
|
{ role: "system", content: TOOLS_SYSTEM_PROMPT },
|
||||||
|
...toOpenAiMessages(messages.filter((m) => m.role !== "system")),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let step = 0; step < 8; step++) {
|
||||||
|
const response = await openai.chat.completions.create(
|
||||||
|
{
|
||||||
|
model: getModelName(),
|
||||||
|
messages: convo,
|
||||||
|
tools: toolDefs,
|
||||||
|
max_tokens: MAX_OUTPUT_TOKENS,
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const choice = response.choices[0]?.message
|
||||||
|
if (!choice) break
|
||||||
|
|
||||||
|
convo.push(choice)
|
||||||
|
|
||||||
|
if (choice.tool_calls?.length) {
|
||||||
|
const toolMessages = await executeToolCalls(choice.tool_calls)
|
||||||
|
for (let i = 0; i < choice.tool_calls.length; i++) {
|
||||||
|
const call = choice.tool_calls[i]
|
||||||
|
const rawContent = toolMessages[i]?.content
|
||||||
|
const raw =
|
||||||
|
typeof rawContent === "string"
|
||||||
|
? rawContent
|
||||||
|
: JSON.stringify(rawContent)
|
||||||
|
let parsedResult: unknown = raw
|
||||||
|
try {
|
||||||
|
parsedResult = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
/* keep string */
|
||||||
|
}
|
||||||
|
trace.push({
|
||||||
|
step: step + 1,
|
||||||
|
toolName: call.function.name,
|
||||||
|
args: JSON.parse(call.function.arguments),
|
||||||
|
result: parsedResult,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
convo.push(...toolMessages)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: choice.content ?? "", toolTrace: trace, memoryDebug: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Tool loop exceeded max steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTypeScriptChat(
|
||||||
|
request: ChatRequest,
|
||||||
|
keys: PlaygroundApiKeys,
|
||||||
|
): Promise<ChatResult> {
|
||||||
|
const sdk = getChatSdk(request.sdkId)
|
||||||
|
if (!sdk || sdk.language !== "typescript" || !sdk.available) {
|
||||||
|
throw new Error(`Invalid TypeScript chat SDK: ${request.sdkId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerTags = request.containerTags ?? [request.containerTag]
|
||||||
|
const memoryMode = request.memoryMode ?? "full"
|
||||||
|
const middlewareConfig = normalizeMiddlewareConfig(request.middlewareConfig)
|
||||||
|
|
||||||
|
const result = await withChatDeadline(async (signal) => {
|
||||||
|
switch (request.sdkId) {
|
||||||
|
case "ts-ai-sdk-middleware":
|
||||||
|
return await chatAiSdkMiddleware(
|
||||||
|
keys,
|
||||||
|
request.messages,
|
||||||
|
request.containerTag,
|
||||||
|
request.conversationId,
|
||||||
|
memoryMode,
|
||||||
|
middlewareConfig,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
case "ts-openai-middleware":
|
||||||
|
return await chatOpenAiMiddleware(
|
||||||
|
keys,
|
||||||
|
request.messages,
|
||||||
|
request.containerTag,
|
||||||
|
request.conversationId,
|
||||||
|
memoryMode,
|
||||||
|
middlewareConfig,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
case "ts-ai-sdk-tools":
|
||||||
|
return await chatAiSdkTools(
|
||||||
|
keys,
|
||||||
|
aiSdkTools,
|
||||||
|
request.messages,
|
||||||
|
containerTags,
|
||||||
|
request.projectId,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
case "ts-openai-tools":
|
||||||
|
return await chatOpenAiTools(
|
||||||
|
keys,
|
||||||
|
request.messages,
|
||||||
|
containerTags,
|
||||||
|
request.projectId,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
case "ts-ai-sdk-package":
|
||||||
|
return await chatAiSdkTools(
|
||||||
|
keys,
|
||||||
|
aiSdkPackageTools,
|
||||||
|
request.messages,
|
||||||
|
containerTags,
|
||||||
|
request.projectId,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unhandled SDK: ${request.sdkId}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.sdkId !== "ts-ai-sdk-middleware" &&
|
||||||
|
request.sdkId !== "ts-openai-middleware"
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryDebug = await buildBestEffortDebug((signal) =>
|
||||||
|
buildMiddlewareMemoryDebug(
|
||||||
|
request.containerTag,
|
||||||
|
request.conversationId,
|
||||||
|
memoryMode,
|
||||||
|
lastUserMessage(request.messages),
|
||||||
|
middlewareConfig,
|
||||||
|
request.sdkId === "ts-ai-sdk-middleware"
|
||||||
|
? {
|
||||||
|
flavor: "ai-sdk",
|
||||||
|
includeToolCalls: middlewareConfig.includeToolCalls,
|
||||||
|
skipMemoryOnError: middlewareConfig.skipMemoryOnError,
|
||||||
|
}
|
||||||
|
: { flavor: "openai" },
|
||||||
|
keys.supermemoryApiKey,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { ...result, memoryDebug }
|
||||||
|
}
|
||||||
294
apps/sdk-playground/src/lib/context-api.ts
Normal file
294
apps/sdk-playground/src/lib/context-api.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
import Supermemory from "supermemory"
|
||||||
|
import {
|
||||||
|
type MiddlewareRuntimeConfig,
|
||||||
|
normalizeMiddlewareConfig,
|
||||||
|
} from "./middleware-config"
|
||||||
|
import {
|
||||||
|
type MemoryMode,
|
||||||
|
type MiddlewareFlavor,
|
||||||
|
reconstructSdkMemoryBlock,
|
||||||
|
} from "./memory-dedupe"
|
||||||
|
|
||||||
|
export interface MemoryDebugEntry {
|
||||||
|
type:
|
||||||
|
| "context_reconstruction"
|
||||||
|
| "context_preview"
|
||||||
|
| "conversation_save_requested"
|
||||||
|
| "conversation_save_accepted"
|
||||||
|
| "conversation_save_failed"
|
||||||
|
| "conversation_save_queued"
|
||||||
|
| "conversation_save_skipped"
|
||||||
|
| "conversation_saved"
|
||||||
|
| "profile_fetch"
|
||||||
|
| "context_debug_unavailable"
|
||||||
|
| "debug_error"
|
||||||
|
| "manual_profile"
|
||||||
|
label: string
|
||||||
|
detail?: Record<string, unknown>
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerContext {
|
||||||
|
containerTag: string
|
||||||
|
query?: string
|
||||||
|
profile: {
|
||||||
|
static: unknown[]
|
||||||
|
dynamic: unknown[]
|
||||||
|
searchResults: unknown[]
|
||||||
|
}
|
||||||
|
documents: Array<{
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
status?: string
|
||||||
|
customId?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
summary?: string
|
||||||
|
memoryEntries?: unknown[]
|
||||||
|
}>
|
||||||
|
pagination?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupermemoryClient(apiKey: string) {
|
||||||
|
if (!apiKey) throw new Error("Supermemory API key is required")
|
||||||
|
return new Supermemory({
|
||||||
|
apiKey,
|
||||||
|
timeout: 10_000,
|
||||||
|
maxRetries: 1,
|
||||||
|
...(process.env.SUPERMEMORY_BASE_URL
|
||||||
|
? { baseURL: process.env.SUPERMEMORY_BASE_URL }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemoryEntries(record: Record<string, unknown>): unknown[] {
|
||||||
|
const raw =
|
||||||
|
record.memoryEntries ??
|
||||||
|
record.memory_entries ??
|
||||||
|
(Array.isArray(record.memories) &&
|
||||||
|
record.memories.length > 0 &&
|
||||||
|
typeof (record.memories[0] as Record<string, unknown>)?.memory === "string"
|
||||||
|
? record.memories
|
||||||
|
: undefined)
|
||||||
|
|
||||||
|
return Array.isArray(raw) ? raw : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryText(item: unknown): string {
|
||||||
|
if (typeof item === "string") return item
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
if (typeof record.memory === "string") return record.memory
|
||||||
|
if (typeof record.content === "string") return record.content
|
||||||
|
if (typeof record.chunk === "string") return record.chunk
|
||||||
|
}
|
||||||
|
return JSON.stringify(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProfile(profile: ContainerContext["profile"]) {
|
||||||
|
return {
|
||||||
|
staticCount: profile.static.length,
|
||||||
|
dynamicCount: profile.dynamic.length,
|
||||||
|
searchResultCount: profile.searchResults.length,
|
||||||
|
staticPreview: profile.static.slice(0, 5).map(memoryText),
|
||||||
|
dynamicPreview: profile.dynamic.slice(0, 5).map(memoryText),
|
||||||
|
searchPreview: profile.searchResults.slice(0, 5).map(memoryText),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchResults(searchResults: unknown): unknown[] {
|
||||||
|
if (!searchResults) return []
|
||||||
|
if (Array.isArray(searchResults)) return searchResults
|
||||||
|
if (typeof searchResults === "object") {
|
||||||
|
const record = searchResults as Record<string, unknown>
|
||||||
|
if (Array.isArray(record.results)) return record.results
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProfileQuery(
|
||||||
|
lastUserMessage: string,
|
||||||
|
mode: "profile" | "query" | "full",
|
||||||
|
): string | undefined {
|
||||||
|
if (mode === "profile") return undefined
|
||||||
|
return lastUserMessage || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfileContext(
|
||||||
|
client: ReturnType<typeof getSupermemoryClient>,
|
||||||
|
containerTag: string,
|
||||||
|
query?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ContainerContext["profile"]> {
|
||||||
|
const profileResponse = await client.post<{
|
||||||
|
profile?: { static?: unknown[]; dynamic?: unknown[] }
|
||||||
|
searchResults?: unknown
|
||||||
|
}>("/v4/profile", {
|
||||||
|
body: {
|
||||||
|
containerTag,
|
||||||
|
include: ["static", "dynamic"],
|
||||||
|
...(query ? { q: query } : {}),
|
||||||
|
},
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
})
|
||||||
|
const profileRaw = profileResponse.profile
|
||||||
|
|
||||||
|
return {
|
||||||
|
static: profileRaw?.static ?? [],
|
||||||
|
dynamic: profileRaw?.dynamic ?? [],
|
||||||
|
searchResults: normalizeSearchResults(profileResponse.searchResults),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchContainerContext(
|
||||||
|
containerTag: string,
|
||||||
|
query?: string,
|
||||||
|
supermemoryApiKey?: string,
|
||||||
|
): Promise<ContainerContext> {
|
||||||
|
const apiKey =
|
||||||
|
supermemoryApiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim()
|
||||||
|
if (!apiKey) throw new Error("Supermemory API key is required")
|
||||||
|
|
||||||
|
const client = getSupermemoryClient(apiKey)
|
||||||
|
const profile = await fetchProfileContext(client, containerTag, query)
|
||||||
|
|
||||||
|
const docsResponse = await client.post<{
|
||||||
|
documents?: unknown[]
|
||||||
|
pagination?: unknown
|
||||||
|
}>("/v3/documents/documents", {
|
||||||
|
body: {
|
||||||
|
containerTags: [containerTag],
|
||||||
|
limit: 25,
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawDocuments = docsResponse.documents ?? []
|
||||||
|
const documents = rawDocuments.map((doc) => {
|
||||||
|
const record = doc as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: record.id as string | undefined,
|
||||||
|
title: record.title as string | undefined,
|
||||||
|
status: record.status as string | undefined,
|
||||||
|
customId: record.customId as string | undefined,
|
||||||
|
createdAt: record.createdAt as string | undefined,
|
||||||
|
updatedAt: record.updatedAt as string | undefined,
|
||||||
|
summary: record.summary as string | undefined,
|
||||||
|
memoryEntries: normalizeMemoryEntries(record),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerTag,
|
||||||
|
query,
|
||||||
|
profile,
|
||||||
|
documents,
|
||||||
|
pagination: docsResponse.pagination,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMiddlewareMemoryDebug(
|
||||||
|
containerTag: string,
|
||||||
|
conversationId: string,
|
||||||
|
memoryMode: MemoryMode,
|
||||||
|
lastUserMessage: string,
|
||||||
|
middlewareConfig: Partial<MiddlewareRuntimeConfig> | undefined,
|
||||||
|
sdk: {
|
||||||
|
flavor: MiddlewareFlavor
|
||||||
|
includeToolCalls?: boolean
|
||||||
|
skipMemoryOnError?: boolean
|
||||||
|
},
|
||||||
|
supermemoryApiKey?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MemoryDebugEntry[]> {
|
||||||
|
const config = normalizeMiddlewareConfig(middlewareConfig)
|
||||||
|
const query = resolveProfileQuery(lastUserMessage, memoryMode)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey =
|
||||||
|
supermemoryApiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim()
|
||||||
|
if (!apiKey) throw new Error("Supermemory API key is required")
|
||||||
|
const profile = await fetchProfileContext(
|
||||||
|
getSupermemoryClient(apiKey),
|
||||||
|
containerTag,
|
||||||
|
query,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
const reconstructed = reconstructSdkMemoryBlock(
|
||||||
|
memoryMode,
|
||||||
|
profile,
|
||||||
|
sdk.flavor,
|
||||||
|
)
|
||||||
|
const selectedProfile = reconstructed.profile
|
||||||
|
const summary = summarizeProfile(selectedProfile)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "context_reconstruction",
|
||||||
|
label: "Post-response context reconstruction",
|
||||||
|
detail: {
|
||||||
|
authoritativeMiddlewareCapture: false,
|
||||||
|
timing: "after model response",
|
||||||
|
endpoint: "POST /v4/profile",
|
||||||
|
containerTag,
|
||||||
|
customId: conversationId,
|
||||||
|
memoryMode,
|
||||||
|
addMemory: config.addMemory,
|
||||||
|
verbose: config.verbose,
|
||||||
|
...(sdk.includeToolCalls !== undefined
|
||||||
|
? { includeToolCalls: sdk.includeToolCalls }
|
||||||
|
: {}),
|
||||||
|
...(sdk.skipMemoryOnError !== undefined
|
||||||
|
? { skipMemoryOnError: sdk.skipMemoryOnError }
|
||||||
|
: {}),
|
||||||
|
query: query ?? null,
|
||||||
|
...summary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "context_preview",
|
||||||
|
label: "Reconstructed SDK-owned memory block (not middleware capture)",
|
||||||
|
preview: reconstructed.block,
|
||||||
|
detail: {
|
||||||
|
totalFacts:
|
||||||
|
summary.staticCount +
|
||||||
|
summary.dynamicCount +
|
||||||
|
summary.searchResultCount,
|
||||||
|
fullLength: reconstructed.block.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config.addMemory === "always"
|
||||||
|
? {
|
||||||
|
type: "conversation_save_requested",
|
||||||
|
label: "Conversation save requested by middleware",
|
||||||
|
detail: {
|
||||||
|
confirmed: false,
|
||||||
|
containerTag,
|
||||||
|
customId: conversationId,
|
||||||
|
addMemory: config.addMemory,
|
||||||
|
verbose: config.verbose,
|
||||||
|
...(sdk.includeToolCalls !== undefined
|
||||||
|
? { includeToolCalls: sdk.includeToolCalls }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "conversation_save_skipped",
|
||||||
|
label: "Conversation saving disabled",
|
||||||
|
detail: { addMemory: config.addMemory },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} catch (error) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "debug_error",
|
||||||
|
label: "Post-response context reconstruction unavailable",
|
||||||
|
detail: {
|
||||||
|
nonFatal: true,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/sdk-playground/src/lib/memory-dedupe.ts
Normal file
75
apps/sdk-playground/src/lib/memory-dedupe.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
deduplicateMemoriesForMode,
|
||||||
|
type ProfileWithMemories,
|
||||||
|
} from "../../../../packages/tools/src/tools-shared"
|
||||||
|
import { wrapMemoryContext } from "../../../../packages/tools/src/shared/memory-context"
|
||||||
|
import {
|
||||||
|
convertProfileToMarkdown,
|
||||||
|
defaultPromptTemplate,
|
||||||
|
} from "../../../../packages/tools/src/shared/prompt-builder"
|
||||||
|
|
||||||
|
export type MemoryMode = "profile" | "query" | "full"
|
||||||
|
export type MiddlewareFlavor = "ai-sdk" | "openai"
|
||||||
|
|
||||||
|
export interface MemoryProfileSlice {
|
||||||
|
static: unknown[]
|
||||||
|
dynamic: unknown[]
|
||||||
|
searchResults: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconstructedMemoryBlock {
|
||||||
|
profile: {
|
||||||
|
static: string[]
|
||||||
|
dynamic: string[]
|
||||||
|
searchResults: string[]
|
||||||
|
}
|
||||||
|
block: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstruct the exact SDK-owned block from a post-response profile snapshot. */
|
||||||
|
export function reconstructSdkMemoryBlock(
|
||||||
|
mode: MemoryMode,
|
||||||
|
profile: MemoryProfileSlice,
|
||||||
|
flavor: MiddlewareFlavor,
|
||||||
|
): ReconstructedMemoryBlock {
|
||||||
|
const deduplicated = deduplicateMemoriesForMode(
|
||||||
|
mode,
|
||||||
|
profile as ProfileWithMemories,
|
||||||
|
)
|
||||||
|
const visibleProfile = {
|
||||||
|
static: deduplicated.static,
|
||||||
|
dynamic: deduplicated.dynamic,
|
||||||
|
searchResults: mode === "profile" ? [] : deduplicated.searchResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMemories =
|
||||||
|
mode === "query"
|
||||||
|
? ""
|
||||||
|
: convertProfileToMarkdown({
|
||||||
|
profile: {
|
||||||
|
static: visibleProfile.static,
|
||||||
|
dynamic: visibleProfile.dynamic,
|
||||||
|
},
|
||||||
|
searchResults: { results: [] },
|
||||||
|
})
|
||||||
|
const generalSearchMemories =
|
||||||
|
mode !== "profile" && visibleProfile.searchResults.length > 0
|
||||||
|
? `Search results for user's recent message: \n${visibleProfile.searchResults
|
||||||
|
.map((memory) => `- ${memory}`)
|
||||||
|
.join("\n")}`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const memories =
|
||||||
|
flavor === "ai-sdk"
|
||||||
|
? defaultPromptTemplate({
|
||||||
|
userMemories,
|
||||||
|
generalSearchMemories,
|
||||||
|
searchResults: [],
|
||||||
|
})
|
||||||
|
: `${userMemories}\n${generalSearchMemories}`.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: visibleProfile,
|
||||||
|
block: wrapMemoryContext(memories),
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/sdk-playground/src/lib/middleware-config.ts
Normal file
30
apps/sdk-playground/src/lib/middleware-config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export type AddMemoryMode = "always" | "never"
|
||||||
|
export type MemoryMode = "profile" | "query" | "full"
|
||||||
|
|
||||||
|
export interface MiddlewareRuntimeConfig {
|
||||||
|
addMemory: AddMemoryMode
|
||||||
|
verbose: boolean
|
||||||
|
includeToolCalls: boolean
|
||||||
|
skipMemoryOnError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MIDDLEWARE_CONFIG: MiddlewareRuntimeConfig = {
|
||||||
|
addMemory: "always",
|
||||||
|
verbose: false,
|
||||||
|
includeToolCalls: false,
|
||||||
|
skipMemoryOnError: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMiddlewareConfig(
|
||||||
|
input?: Partial<MiddlewareRuntimeConfig> | null,
|
||||||
|
): MiddlewareRuntimeConfig {
|
||||||
|
if (!input) return { ...DEFAULT_MIDDLEWARE_CONFIG }
|
||||||
|
return {
|
||||||
|
addMemory: input.addMemory ?? DEFAULT_MIDDLEWARE_CONFIG.addMemory,
|
||||||
|
verbose: input.verbose ?? DEFAULT_MIDDLEWARE_CONFIG.verbose,
|
||||||
|
includeToolCalls:
|
||||||
|
input.includeToolCalls ?? DEFAULT_MIDDLEWARE_CONFIG.includeToolCalls,
|
||||||
|
skipMemoryOnError:
|
||||||
|
input.skipMemoryOnError ?? DEFAULT_MIDDLEWARE_CONFIG.skipMemoryOnError,
|
||||||
|
}
|
||||||
|
}
|
||||||
354
apps/sdk-playground/src/lib/request-validation.ts
Normal file
354
apps/sdk-playground/src/lib/request-validation.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import type { PlaygroundApiKeys } from "./api-keys"
|
||||||
|
import type { ChatMessage } from "./chat-handlers"
|
||||||
|
import type { MiddlewareRuntimeConfig } from "./middleware-config"
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 256_000
|
||||||
|
const MAX_MESSAGES = 64
|
||||||
|
const MAX_MESSAGE_LENGTH = 20_000
|
||||||
|
const MAX_TOTAL_MESSAGE_LENGTH = 100_000
|
||||||
|
const MAX_IDENTIFIER_LENGTH = 256
|
||||||
|
const MAX_API_KEY_LENGTH = 1_024
|
||||||
|
const MAX_CONTAINER_TAG_LENGTH = 100
|
||||||
|
const MAX_CONVERSATION_ID_LENGTH = 242
|
||||||
|
const CONTAINER_TAG_PATTERN = /^[a-zA-Z0-9_:-]+$/
|
||||||
|
|
||||||
|
export class PlaygroundRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PlaygroundRequestError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstHeaderValue(value: string | null): string | null {
|
||||||
|
const first = value?.split(",", 1)[0]?.trim()
|
||||||
|
return first || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOrigin(request: Request): string | null {
|
||||||
|
const internalUrl = new URL(request.url)
|
||||||
|
const forwardedHostHeader = request.headers.get("x-forwarded-host")
|
||||||
|
const forwardedProtoHeader = request.headers.get("x-forwarded-proto")
|
||||||
|
const hostHeader = request.headers.get("host")
|
||||||
|
const directHost = firstHeaderValue(hostHeader)
|
||||||
|
if (hostHeader !== null && !directHost) return null
|
||||||
|
const forwardedHost = firstHeaderValue(forwardedHostHeader)
|
||||||
|
if (forwardedHostHeader !== null && !forwardedHost) return null
|
||||||
|
// Portless preserves the routed Host header but may preserve a client-supplied
|
||||||
|
// X-Forwarded-Host. Trust Host first so XFH cannot widen env-key access.
|
||||||
|
const host = directHost ?? forwardedHost
|
||||||
|
if (!host) return internalUrl.origin
|
||||||
|
|
||||||
|
const forwardedProto = firstHeaderValue(forwardedProtoHeader)
|
||||||
|
if (
|
||||||
|
forwardedProtoHeader !== null &&
|
||||||
|
forwardedProto !== "http" &&
|
||||||
|
forwardedProto !== "https"
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const protocol =
|
||||||
|
forwardedProto === "http" || forwardedProto === "https"
|
||||||
|
? forwardedProto
|
||||||
|
: internalUrl.protocol.slice(0, -1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const externalUrl = new URL(`${protocol}://${host}`)
|
||||||
|
if (
|
||||||
|
externalUrl.host.toLowerCase() !== host.toLowerCase() ||
|
||||||
|
externalUrl.username ||
|
||||||
|
externalUrl.password ||
|
||||||
|
externalUrl.pathname !== "/" ||
|
||||||
|
externalUrl.search ||
|
||||||
|
externalUrl.hash
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return externalUrl.origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTrustedBrowserRequest(request: Request): void {
|
||||||
|
if (request.headers.get("sec-fetch-site") === "cross-site") {
|
||||||
|
throw new PlaygroundRequestError("Cross-site requests are not allowed", 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedOrigin = requestOrigin(request)
|
||||||
|
if (!expectedOrigin) {
|
||||||
|
throw new PlaygroundRequestError("Request host is not allowed", 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = request.headers.get("origin")
|
||||||
|
let normalizedOrigin: string | null = null
|
||||||
|
if (origin) {
|
||||||
|
try {
|
||||||
|
normalizedOrigin = new URL(origin).origin
|
||||||
|
} catch {
|
||||||
|
throw new PlaygroundRequestError("Request origin is not allowed", 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedOrigin && normalizedOrigin !== expectedOrigin) {
|
||||||
|
throw new PlaygroundRequestError("Request origin is not allowed", 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mayUseEnvironmentKeys(request: Request): boolean {
|
||||||
|
if (process.env.SDK_PLAYGROUND_ALLOW_ENV_KEYS === "true") return true
|
||||||
|
|
||||||
|
const origin = requestOrigin(request)
|
||||||
|
if (!origin) return false
|
||||||
|
const hostname = new URL(origin).hostname.toLowerCase()
|
||||||
|
return (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "[::1]" ||
|
||||||
|
hostname.endsWith(".localhost") ||
|
||||||
|
hostname === "sdk.dev.supermemory.ai"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonObject(
|
||||||
|
request: Request,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const contentType = request.headers.get("content-type") ?? ""
|
||||||
|
if (!contentType.toLowerCase().startsWith("application/json")) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
"Content-Type must be application/json",
|
||||||
|
415,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(request.headers.get("content-length"))
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
|
||||||
|
throw new PlaygroundRequestError("Request body is too large", 413)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.text()
|
||||||
|
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) {
|
||||||
|
throw new PlaygroundRequestError("Request body is too large", 413)
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: unknown
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
throw new PlaygroundRequestError("Request body must be valid JSON", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new PlaygroundRequestError("Request body must be a JSON object", 400)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessages(value: unknown): ChatMessage[] {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
"At least one chat message is required",
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (value.length > MAX_MESSAGES) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`A maximum of ${MAX_MESSAGES} messages is allowed`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalLength = 0
|
||||||
|
const messages = value.map((item, index): ChatMessage => {
|
||||||
|
if (!isRecord(item)) {
|
||||||
|
throw new PlaygroundRequestError(`Message ${index + 1} is invalid`, 400)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item.role !== "user" &&
|
||||||
|
item.role !== "assistant" &&
|
||||||
|
item.role !== "system"
|
||||||
|
) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`Message ${index + 1} has an invalid role`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (typeof item.content !== "string") {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`Message ${index + 1} content must be text`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.content.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`Message ${index + 1} exceeds ${MAX_MESSAGE_LENGTH} characters`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
totalLength += item.content.length
|
||||||
|
return { role: item.role, content: item.content }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (totalLength > MAX_TOTAL_MESSAGE_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError("Chat history is too large", 400)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!messages.some(
|
||||||
|
(message) => message.role === "user" && message.content.trim().length > 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
"Chat history must include a non-empty user message",
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIdentifier(
|
||||||
|
value: unknown,
|
||||||
|
name: string,
|
||||||
|
fallback?: string,
|
||||||
|
): string {
|
||||||
|
const resolved = typeof value === "string" ? value.trim() : fallback
|
||||||
|
if (!resolved) {
|
||||||
|
throw new PlaygroundRequestError(`${name} is required`, 400)
|
||||||
|
}
|
||||||
|
if (resolved.length > MAX_IDENTIFIER_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`${name} must be ${MAX_IDENTIFIER_LENGTH} characters or fewer`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContainerTag(
|
||||||
|
value: unknown,
|
||||||
|
fallback = "sdk-playground",
|
||||||
|
): string {
|
||||||
|
const containerTag = parseIdentifier(value, "containerTag", fallback)
|
||||||
|
if (containerTag.length > MAX_CONTAINER_TAG_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`containerTag must be ${MAX_CONTAINER_TAG_LENGTH} characters or fewer`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!CONTAINER_TAG_PATTERN.test(containerTag)) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
"containerTag may only contain letters, numbers, hyphens, underscores, and colons",
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return containerTag
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConversationId(value: unknown): string {
|
||||||
|
const conversationId = parseIdentifier(value, "conversationId")
|
||||||
|
if (conversationId.length > MAX_CONVERSATION_ID_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`conversationId must be ${MAX_CONVERSATION_ID_LENGTH} characters or fewer`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return conversationId
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalText(
|
||||||
|
value: unknown,
|
||||||
|
name: string,
|
||||||
|
maxLength = MAX_MESSAGE_LENGTH,
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") return undefined
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new PlaygroundRequestError(`${name} must be text`, 400)
|
||||||
|
}
|
||||||
|
const resolved = value.trim()
|
||||||
|
if (!resolved) return undefined
|
||||||
|
if (resolved.length > maxLength) {
|
||||||
|
throw new PlaygroundRequestError(
|
||||||
|
`${name} must be ${maxLength} characters or fewer`,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMemoryMode(
|
||||||
|
value: unknown,
|
||||||
|
): "profile" | "query" | "full" | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined
|
||||||
|
if (value === "profile" || value === "query" || value === "full") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
throw new PlaygroundRequestError("Invalid memory mode", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMiddlewareConfig(
|
||||||
|
value: unknown,
|
||||||
|
): Partial<MiddlewareRuntimeConfig> | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new PlaygroundRequestError("Invalid middleware configuration", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.addMemory !== undefined &&
|
||||||
|
value.addMemory !== "always" &&
|
||||||
|
value.addMemory !== "never"
|
||||||
|
) {
|
||||||
|
throw new PlaygroundRequestError("Invalid addMemory value", 400)
|
||||||
|
}
|
||||||
|
for (const key of [
|
||||||
|
"verbose",
|
||||||
|
"includeToolCalls",
|
||||||
|
"skipMemoryOnError",
|
||||||
|
] as const) {
|
||||||
|
if (value[key] !== undefined && typeof value[key] !== "boolean") {
|
||||||
|
throw new PlaygroundRequestError(`Invalid ${key} value`, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(value.addMemory !== undefined
|
||||||
|
? { addMemory: value.addMemory as "always" | "never" }
|
||||||
|
: {}),
|
||||||
|
...(value.verbose !== undefined
|
||||||
|
? { verbose: value.verbose as boolean }
|
||||||
|
: {}),
|
||||||
|
...(value.includeToolCalls !== undefined
|
||||||
|
? { includeToolCalls: value.includeToolCalls as boolean }
|
||||||
|
: {}),
|
||||||
|
...(value.skipMemoryOnError !== undefined
|
||||||
|
? { skipMemoryOnError: value.skipMemoryOnError as boolean }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseApiKeys(value: unknown): Partial<PlaygroundApiKeys> {
|
||||||
|
if (value === undefined || value === null) return {}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new PlaygroundRequestError("Invalid API key configuration", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supermemoryApiKey: parseOptionalApiKey(
|
||||||
|
value.supermemoryApiKey,
|
||||||
|
"Supermemory API key",
|
||||||
|
),
|
||||||
|
openaiApiKey: parseOptionalApiKey(value.openaiApiKey, "OpenAI API key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalApiKey(value: unknown, name: string): string {
|
||||||
|
if (value === undefined || value === null || value === "") return ""
|
||||||
|
if (typeof value !== "string" || value.length > MAX_API_KEY_LENGTH) {
|
||||||
|
throw new PlaygroundRequestError(`${name} is invalid`, 400)
|
||||||
|
}
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
116
apps/sdk-playground/src/lib/sdk-registry.ts
Normal file
116
apps/sdk-playground/src/lib/sdk-registry.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
export type SdkLanguage = "typescript" | "python"
|
||||||
|
export type IntegrationMode = "middleware" | "tools" | "direct"
|
||||||
|
|
||||||
|
export interface ToolTraceEntry {
|
||||||
|
step: number
|
||||||
|
toolName: string
|
||||||
|
args: unknown
|
||||||
|
result?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSdkDefinition {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
language: SdkLanguage
|
||||||
|
mode: IntegrationMode
|
||||||
|
package: string
|
||||||
|
description: string
|
||||||
|
available: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAT_SDK_REGISTRY: ChatSdkDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "ts-ai-sdk-middleware",
|
||||||
|
label: "AI SDK + middleware",
|
||||||
|
language: "typescript",
|
||||||
|
mode: "middleware",
|
||||||
|
package: "@supermemory/tools/ai-sdk",
|
||||||
|
description:
|
||||||
|
"withSupermemory wraps the model — auto-injects profile/search and saves conversations",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ts-openai-middleware",
|
||||||
|
label: "OpenAI SDK + middleware",
|
||||||
|
language: "typescript",
|
||||||
|
mode: "middleware",
|
||||||
|
package: "@supermemory/tools/openai",
|
||||||
|
description:
|
||||||
|
"withSupermemory on OpenAI client — same automatic memory path",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ts-ai-sdk-tools",
|
||||||
|
label: "AI SDK + tools",
|
||||||
|
language: "typescript",
|
||||||
|
mode: "tools",
|
||||||
|
package: "@supermemory/tools/ai-sdk",
|
||||||
|
description:
|
||||||
|
"Agent explicitly calls the 7 Supermemory tools via generateText",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ts-openai-tools",
|
||||||
|
label: "OpenAI SDK + tools",
|
||||||
|
language: "typescript",
|
||||||
|
mode: "tools",
|
||||||
|
package: "@supermemory/tools/openai",
|
||||||
|
description: "OpenAI function calling with the 7 Supermemory tools",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ts-ai-sdk-package",
|
||||||
|
label: "@supermemory/ai-sdk",
|
||||||
|
language: "typescript",
|
||||||
|
mode: "tools",
|
||||||
|
package: "@supermemory/ai-sdk",
|
||||||
|
description: "Re-export of tools/ai-sdk — same 7-tool agent",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "py-openai-middleware",
|
||||||
|
label: "OpenAI + middleware",
|
||||||
|
language: "python",
|
||||||
|
mode: "middleware",
|
||||||
|
package: "supermemory-openai-sdk",
|
||||||
|
description:
|
||||||
|
"with_supermemory — automatic profile injection + conversation save",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "py-openai-tools",
|
||||||
|
label: "OpenAI + tools",
|
||||||
|
language: "python",
|
||||||
|
mode: "tools",
|
||||||
|
package: "supermemory-openai-sdk",
|
||||||
|
description: "SupermemoryTools function-calling loop (7 tools)",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "py-supermemory-direct",
|
||||||
|
label: "supermemory + manual context",
|
||||||
|
language: "python",
|
||||||
|
mode: "direct",
|
||||||
|
package: "supermemory",
|
||||||
|
description: "profile() then OpenAI — manual integration pattern from docs",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PYTHON_SERVER_URL =
|
||||||
|
process.env.SDK_PLAYGROUND_PYTHON_URL ?? "http://127.0.0.1:8792"
|
||||||
|
|
||||||
|
export const TOOLS_SYSTEM_PROMPT = `You are a helpful assistant with Supermemory long-term memory.
|
||||||
|
|
||||||
|
You have tools to manage memory. Use them proactively:
|
||||||
|
- searchMemories: hybrid recall — search before answering whenever user-specific context could help (do not wait to be asked)
|
||||||
|
- getProfile: broad static/dynamic user context at conversation start or when you need a wide overview
|
||||||
|
- addMemory: store a new generalizable fact
|
||||||
|
- documentList / documentAdd / documentDelete: manage source documents (documentDelete is permanent)
|
||||||
|
- memoryForget: soft-delete one profile fact by memoryId or exact content (not whole documents)
|
||||||
|
|
||||||
|
Before answering questions about the user, their preferences, or past context, search memories or get profile first. When the user asks you to remember something, use addMemory.`
|
||||||
|
|
||||||
|
export function getChatSdk(id: string): ChatSdkDefinition | undefined {
|
||||||
|
return CHAT_SDK_REGISTRY.find((s) => s.id === id)
|
||||||
|
}
|
||||||
160
apps/sdk-playground/src/lib/tools-catalog.ts
Normal file
160
apps/sdk-playground/src/lib/tools-catalog.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import {
|
||||||
|
PARAMETER_DESCRIPTIONS,
|
||||||
|
TOOL_DESCRIPTIONS,
|
||||||
|
} from "../../../../packages/tools/src/tools-shared"
|
||||||
|
|
||||||
|
export interface CatalogParameter {
|
||||||
|
name: string
|
||||||
|
/** Omitted when this parameter is only exposed by the TypeScript tool schema. */
|
||||||
|
pythonName?: string
|
||||||
|
description: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogTool {
|
||||||
|
id: string
|
||||||
|
pythonName: string
|
||||||
|
description: string
|
||||||
|
parameters: CatalogParameter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOOL_CATALOG: CatalogTool[] = [
|
||||||
|
{
|
||||||
|
id: "searchMemories",
|
||||||
|
pythonName: "search_memories",
|
||||||
|
description: TOOL_DESCRIPTIONS.searchMemories,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "informationToGet",
|
||||||
|
pythonName: "information_to_get",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.informationToGet,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "includeFullDocs",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.includeFullDocs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit",
|
||||||
|
pythonName: "limit",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.limit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "addMemory",
|
||||||
|
pythonName: "add_memory",
|
||||||
|
description: TOOL_DESCRIPTIONS.addMemory,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "memory",
|
||||||
|
pythonName: "memory",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.memory,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "getProfile",
|
||||||
|
pythonName: "get_profile",
|
||||||
|
description: TOOL_DESCRIPTIONS.getProfile,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "containerTag",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.containerTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query",
|
||||||
|
pythonName: "query",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.query,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documentList",
|
||||||
|
pythonName: "document_list",
|
||||||
|
description: TOOL_DESCRIPTIONS.documentList,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "containerTag",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.containerTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit",
|
||||||
|
pythonName: "limit",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.limit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page",
|
||||||
|
pythonName: "page",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.page,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documentDelete",
|
||||||
|
pythonName: "document_delete",
|
||||||
|
description: TOOL_DESCRIPTIONS.documentDelete,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "documentId",
|
||||||
|
pythonName: "document_id",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.documentId,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "containerTag",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "documentAdd",
|
||||||
|
pythonName: "document_add",
|
||||||
|
description: TOOL_DESCRIPTIONS.documentAdd,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
pythonName: "content",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.content,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
pythonName: "title",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
pythonName: "description",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.description,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memoryForget",
|
||||||
|
pythonName: "memory_forget",
|
||||||
|
description: TOOL_DESCRIPTIONS.memoryForget,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "containerTag",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.containerTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memoryId",
|
||||||
|
pythonName: "memory_id",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.memoryId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memoryContent",
|
||||||
|
pythonName: "memory_content",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.memoryContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reason",
|
||||||
|
pythonName: "reason",
|
||||||
|
description: PARAMETER_DESCRIPTIONS.reason,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
33
apps/sdk-playground/tsconfig.json
Normal file
33
apps/sdk-playground/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
|
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
|
||||||
NEXT_PUBLIC_POSTHOG_KEY=
|
NEXT_PUBLIC_POSTHOG_KEY=
|
||||||
EXA_API_KEY=
|
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=
|
||||||
XAI_API_KEY=
|
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,14 @@ import { useRouter } from "next/navigation"
|
||||||
import { LogoFull } from "@ui/assets/Logo"
|
import { LogoFull } from "@ui/assets/Logo"
|
||||||
import { Button } from "@ui/components/button"
|
import { Button } from "@ui/components/button"
|
||||||
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
|
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
|
||||||
import { authClient } from "@lib/auth"
|
|
||||||
import { useAuth } from "@lib/auth-context"
|
import { useAuth } from "@lib/auth-context"
|
||||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
|
||||||
import { cn } from "@lib/utils"
|
import { cn } from "@lib/utils"
|
||||||
import { analytics } from "@/lib/analytics"
|
|
||||||
import {
|
import {
|
||||||
type BrainEntryOrganization,
|
type BrainEntryOrganization,
|
||||||
resolveCompanyBrainEntry,
|
resolveCompanyBrainEntry,
|
||||||
} from "@/lib/company-brain-entry"
|
} from "@/lib/company-brain-entry"
|
||||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||||
import {
|
import { generateOrgSlug } from "@/components/onboarding-brain/types"
|
||||||
detectModeFromEmail,
|
|
||||||
generateOrgSlug,
|
|
||||||
workspaceDomainFromEmail,
|
|
||||||
workspaceNameFromDomain,
|
|
||||||
workspaceNameFromEmail,
|
|
||||||
type BrainMetadata,
|
|
||||||
} from "@/components/onboarding-brain/types"
|
|
||||||
|
|
||||||
const BACKEND =
|
const BACKEND =
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||||
|
|
@ -37,21 +27,13 @@ const inputBevelStyle = {
|
||||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||||
}
|
}
|
||||||
|
|
||||||
// No forms: sign up → org auto-created → Slack install.
|
|
||||||
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
|
|
||||||
export default function BrainEntryPage() {
|
export default function BrainEntryPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const {
|
const { user, org, organizations, isRestoring, setActiveOrg } = useAuth()
|
||||||
user,
|
|
||||||
org,
|
|
||||||
organizations,
|
|
||||||
isRestoring,
|
|
||||||
setActiveOrg,
|
|
||||||
refetchOrganizations,
|
|
||||||
} = useAuth()
|
|
||||||
const { email = null } = user ?? {}
|
const { email = null } = user ?? {}
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
|
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
|
||||||
|
const [closed, setClosed] = useState(false)
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0)
|
||||||
const startedRef = useRef(false)
|
const startedRef = useRef(false)
|
||||||
|
|
||||||
|
|
@ -75,43 +57,6 @@ export default function BrainEntryPage() {
|
||||||
[org?.id, router, setActiveOrg],
|
[org?.id, router, setActiveOrg],
|
||||||
)
|
)
|
||||||
|
|
||||||
const createCompanyBrain = useCallback(async () => {
|
|
||||||
// Personal email → shell org; the Slack workspace resolves identity later.
|
|
||||||
const domain =
|
|
||||||
detectModeFromEmail(email) === "team"
|
|
||||||
? workspaceDomainFromEmail(email)
|
|
||||||
: null
|
|
||||||
const name =
|
|
||||||
(domain
|
|
||||||
? workspaceNameFromDomain(domain)
|
|
||||||
: workspaceNameFromEmail(email)) || "Company Brain"
|
|
||||||
const metadata: BrainMetadata & { signupSource: string } = {
|
|
||||||
signupSource: "consumer",
|
|
||||||
brainOnboardingVersion: "v1",
|
|
||||||
brainMode: "team",
|
|
||||||
brainWorkspaceName: name,
|
|
||||||
brainWorkspaceDomain: domain,
|
|
||||||
// Always the shared Team Brain; the CB UI never selects a slug space.
|
|
||||||
brainContainerTag: SHARED_TEAM_BRAIN_TAG,
|
|
||||||
}
|
|
||||||
const result = await authClient.organization.create({
|
|
||||||
name,
|
|
||||||
slug: generateOrgSlug(name),
|
|
||||||
metadata,
|
|
||||||
})
|
|
||||||
if (result.error || !result.data?.slug) {
|
|
||||||
throw new Error(result.error?.message || "Could not create workspace.")
|
|
||||||
}
|
|
||||||
await setActiveOrg(result.data.slug)
|
|
||||||
await refetchOrganizations()
|
|
||||||
analytics.onboardingWorkspaceCreated({
|
|
||||||
mode: "team",
|
|
||||||
has_about: false,
|
|
||||||
has_domain: Boolean(domain),
|
|
||||||
})
|
|
||||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
|
||||||
}, [email, refetchOrganizations, setActiveOrg])
|
|
||||||
|
|
||||||
const run = useCallback(async () => {
|
const run = useCallback(async () => {
|
||||||
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
const organizationsWithActiveMetadata = (organizations ?? []).map(
|
||||||
(organization) =>
|
(organization) =>
|
||||||
|
|
@ -132,8 +77,8 @@ export default function BrainEntryPage() {
|
||||||
setChoices(decision.organizations)
|
setChoices(decision.organizations)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await createCompanyBrain()
|
setClosed(true)
|
||||||
}, [continueWithOrganization, createCompanyBrain, org, organizations])
|
}, [continueWithOrganization, org, organizations])
|
||||||
|
|
||||||
const handleChoice = useCallback(
|
const handleChoice = useCallback(
|
||||||
(organization: BrainEntryOrganization) => {
|
(organization: BrainEntryOrganization) => {
|
||||||
|
|
@ -164,7 +109,32 @@ export default function BrainEntryPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryShell>
|
<EntryShell>
|
||||||
{choices ? (
|
{closed ? (
|
||||||
|
<section
|
||||||
|
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
|
||||||
|
style={modalCardStyle}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-[20px] font-semibold text-[#fafafa]",
|
||||||
|
dmSans125ClassName(),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
New signups are paused
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
|
||||||
|
Company Brain isn't accepting new workspaces right now. If you have
|
||||||
|
questions, reach us at support@supermemory.com.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="insideOut"
|
||||||
|
onClick={() => router.replace("/")}
|
||||||
|
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||||
|
>
|
||||||
|
Go to Supermemory
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : choices ? (
|
||||||
<section
|
<section
|
||||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
|
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
|
||||||
style={modalCardStyle}
|
style={modalCardStyle}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,22 @@ import {
|
||||||
|
|
||||||
export default async function ConfigureSectionPage({
|
export default async function ConfigureSectionPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ section: string }>
|
params: Promise<{ section: string }>
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
}) {
|
}) {
|
||||||
const { section } = await params
|
const { section } = await params
|
||||||
// Default section is canonical at /configure.
|
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
|
||||||
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
|
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()
|
if (!isConfigureSection(section)) notFound()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||||
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
||||||
|
import { PromoCodeHost } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SettingsModalProvider>
|
<SettingsModalProvider>
|
||||||
|
<PromoCodeHost />
|
||||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||||
<PWAInstallPrompt />
|
<PWAInstallPrompt />
|
||||||
</SettingsModalProvider>
|
</SettingsModalProvider>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context"
|
||||||
import { authClient } from "@lib/auth"
|
import { authClient } from "@lib/auth"
|
||||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||||
import { analytics } from "@/lib/analytics"
|
import { analytics } from "@/lib/analytics"
|
||||||
|
import { hasCompanyBrain } from "@/lib/billing-utils"
|
||||||
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
|
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
|
||||||
import { BrainShell } from "@/components/onboarding-brain/shell"
|
import { BrainShell } from "@/components/onboarding-brain/shell"
|
||||||
import {
|
import {
|
||||||
|
|
@ -107,6 +108,22 @@ export default function BrainOnboardingPage() {
|
||||||
[user?.email],
|
[user?.email],
|
||||||
)
|
)
|
||||||
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
||||||
|
|
||||||
|
const hasCompanyBrainOrg = useMemo(
|
||||||
|
() =>
|
||||||
|
(organizations ?? []).some((o) =>
|
||||||
|
hasCompanyBrain(
|
||||||
|
(o as { metadata?: Record<string, unknown> | string | null })
|
||||||
|
.metadata,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[organizations],
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "team" && organizations != null && !hasCompanyBrainOrg) {
|
||||||
|
setMode("personal")
|
||||||
|
}
|
||||||
|
}, [mode, organizations, hasCompanyBrainOrg])
|
||||||
const [about, setAbout] = useState<AboutValues>({
|
const [about, setAbout] = useState<AboutValues>({
|
||||||
name: user?.name ?? "",
|
name: user?.name ?? "",
|
||||||
about: "",
|
about: "",
|
||||||
|
|
@ -280,12 +297,9 @@ export default function BrainOnboardingPage() {
|
||||||
const metadata: BrainMetadata & { signupSource: string } = {
|
const metadata: BrainMetadata & { signupSource: string } = {
|
||||||
signupSource: "consumer",
|
signupSource: "consumer",
|
||||||
brainOnboardingVersion: "v1",
|
brainOnboardingVersion: "v1",
|
||||||
brainMode: mode,
|
brainMode: "personal",
|
||||||
brainWorkspaceName: name,
|
brainWorkspaceName: name,
|
||||||
brainWorkspaceDomain:
|
brainWorkspaceDomain: null,
|
||||||
mode === "team"
|
|
||||||
? domainOverride || about.workspaceDomain || domain
|
|
||||||
: null,
|
|
||||||
brainContainerTag: containerTag,
|
brainContainerTag: containerTag,
|
||||||
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
||||||
}
|
}
|
||||||
|
|
@ -368,7 +382,7 @@ export default function BrainOnboardingPage() {
|
||||||
setCreatingOrg(false)
|
setCreatingOrg(false)
|
||||||
}
|
}
|
||||||
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
||||||
const isCompanyBrain = mode === "team"
|
const isCompanyBrain = mode === "team" && hasCompanyBrainOrg
|
||||||
|
|
||||||
const handleBrainConfirm = useCallback(
|
const handleBrainConfirm = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -385,10 +399,17 @@ export default function BrainOnboardingPage() {
|
||||||
workspaceDomain: confirmedDomain,
|
workspaceDomain: confirmedDomain,
|
||||||
workspaceName: workspaceName || a.workspaceName,
|
workspaceName: workspaceName || a.workspaceName,
|
||||||
}))
|
}))
|
||||||
let orgCreated = false
|
const signupsPaused = () => {
|
||||||
|
toast.error("New Company Brain signups are paused", {
|
||||||
|
description: "Questions? Reach us at support@supermemory.com.",
|
||||||
|
})
|
||||||
|
return { ok: false as const }
|
||||||
|
}
|
||||||
|
const orgCreated = false
|
||||||
if (forceCreate) {
|
if (forceCreate) {
|
||||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
return signupsPaused()
|
||||||
} else if (organizationId) {
|
}
|
||||||
|
if (organizationId) {
|
||||||
const selected = organizations?.find(
|
const selected = organizations?.find(
|
||||||
(organization) => organization.id === organizationId,
|
(organization) => organization.id === organizationId,
|
||||||
)
|
)
|
||||||
|
|
@ -418,7 +439,7 @@ export default function BrainOnboardingPage() {
|
||||||
if (decision.action === "switch") {
|
if (decision.action === "switch") {
|
||||||
await setActiveOrg(decision.organization.slug)
|
await setActiveOrg(decision.organization.slug)
|
||||||
} else if (decision.action === "create") {
|
} else if (decision.action === "create") {
|
||||||
orgCreated = await ensureOrg(confirmedDomain, true)
|
return signupsPaused()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-entering onboarding on an existing org ("Try onboarding") must
|
// Re-entering onboarding on an existing org ("Try onboarding") must
|
||||||
|
|
@ -475,15 +496,7 @@ export default function BrainOnboardingPage() {
|
||||||
setCreatingOrg(false)
|
setCreatingOrg(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[forceCreate, org, organizations, queryClient, setActiveOrg, router],
|
||||||
ensureOrg,
|
|
||||||
forceCreate,
|
|
||||||
org,
|
|
||||||
organizations,
|
|
||||||
queryClient,
|
|
||||||
setActiveOrg,
|
|
||||||
router,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const [sendingInvites, setSendingInvites] = useState(false)
|
const [sendingInvites, setSendingInvites] = useState(false)
|
||||||
|
|
@ -541,6 +554,10 @@ export default function BrainOnboardingPage() {
|
||||||
|
|
||||||
const mcpUrl = "https://mcp.supermemory.ai/mcp"
|
const mcpUrl = "https://mcp.supermemory.ai/mcp"
|
||||||
|
|
||||||
|
if (mode === "team" && organizations == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (isCompanyBrain) {
|
if (isCompanyBrain) {
|
||||||
return (
|
return (
|
||||||
<CompanyBrainOnboarding
|
<CompanyBrainOnboarding
|
||||||
|
|
@ -565,10 +582,6 @@ export default function BrainOnboardingPage() {
|
||||||
{step === "about" && (
|
{step === "about" && (
|
||||||
<StepAbout
|
<StepAbout
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onModeChange={(m) => {
|
|
||||||
analytics.onboardingModeSelected({ mode: m })
|
|
||||||
setMode(m)
|
|
||||||
}}
|
|
||||||
domain={domain}
|
domain={domain}
|
||||||
suggestedWorkspaceName={suggestedWorkspaceName}
|
suggestedWorkspaceName={suggestedWorkspaceName}
|
||||||
defaultName={user?.name ?? ""}
|
defaultName={user?.name ?? ""}
|
||||||
|
|
|
||||||
|
|
@ -591,6 +591,79 @@ export default function LoginPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
|
|
||||||
<TextSeparator
|
<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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { hasVerifiedSession } from "@/lib/verify-session"
|
||||||
|
|
||||||
interface OGResponse {
|
interface OGResponse {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -13,6 +15,42 @@ function isValidUrl(urlString: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_HTML_BYTES = 2_000_000
|
||||||
|
|
||||||
|
// OG parsing only needs <head>, so cap the read rather than buffering the whole body.
|
||||||
|
async function readBoundedText(
|
||||||
|
response: Response,
|
||||||
|
maxBytes = MAX_HTML_BYTES,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const contentLength = response.headers.get("content-length")
|
||||||
|
if (contentLength && Number(contentLength) > maxBytes) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let total = 0
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
total += value.byteLength
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel().catch(() => {})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
chunks.push(value)
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(total)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
merged.set(chunk, offset)
|
||||||
|
offset += chunk.byteLength
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(merged)
|
||||||
|
}
|
||||||
|
|
||||||
function isPrivateIPv4Octets(a: number, b: number): boolean {
|
function isPrivateIPv4Octets(a: number, b: number): boolean {
|
||||||
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
|
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
|
||||||
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
|
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
|
||||||
|
|
@ -247,6 +285,10 @@ function resolveImageUrl(
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
if (!(await hasVerifiedSession(request))) {
|
||||||
|
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const url = searchParams.get("url")
|
const url = searchParams.get("url")
|
||||||
|
|
||||||
|
|
@ -332,7 +374,13 @@ export async function GET(request: Request) {
|
||||||
if (contentType && !contentType.includes("text/html")) {
|
if (contentType && !contentType.includes("text/html")) {
|
||||||
return Response.json({ title: "", description: "" })
|
return Response.json({ title: "", description: "" })
|
||||||
}
|
}
|
||||||
const html = await secondResponse.text()
|
const html = await readBoundedText(secondResponse)
|
||||||
|
if (html === null) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Response too large" },
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
return processHtml(html, redirectUrl)
|
return processHtml(html, redirectUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +397,10 @@ export async function GET(request: Request) {
|
||||||
return Response.json({ title: "", description: "" })
|
return Response.json({ title: "", description: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text()
|
const html = await readBoundedText(response)
|
||||||
|
if (html === null) {
|
||||||
|
return Response.json({ error: "Response too large" }, { status: 413 })
|
||||||
|
}
|
||||||
return processHtml(html, trimmedUrl)
|
return processHtml(html, trimmedUrl)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
type AccountSource = "x" | "linkedin"
|
|
||||||
|
|
||||||
type ParsedAccount = {
|
|
||||||
handle: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseXAccount(value: string): ParsedAccount | null {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return null
|
|
||||||
|
|
||||||
let handle = trimmed.replace(/^@/, "")
|
|
||||||
const lowerValue = handle.toLowerCase()
|
|
||||||
|
|
||||||
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
|
|
||||||
try {
|
|
||||||
const url = new URL(
|
|
||||||
handle.startsWith("http://") || handle.startsWith("https://")
|
|
||||||
? handle
|
|
||||||
: `https://${handle}`,
|
|
||||||
)
|
|
||||||
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
|
|
||||||
} catch {
|
|
||||||
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
|
|
||||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
|
|
||||||
|
|
||||||
return { handle, url: `https://x.com/${handle}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLinkedInAccount(value: string): ParsedAccount | null {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(
|
|
||||||
trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
|
||||||
? trimmed
|
|
||||||
: `https://${trimmed}`,
|
|
||||||
)
|
|
||||||
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
|
|
||||||
const handle = match?.[2]
|
|
||||||
if (!handle) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
handle,
|
|
||||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
|
|
||||||
const handle = match?.[2]
|
|
||||||
if (!handle) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
handle,
|
|
||||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAccount(
|
|
||||||
source: AccountSource,
|
|
||||||
value: string,
|
|
||||||
): ParsedAccount | null {
|
|
||||||
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksUnavailable(source: AccountSource, html: string) {
|
|
||||||
const lowerHtml = html.toLowerCase()
|
|
||||||
if (source === "x") {
|
|
||||||
return (
|
|
||||||
lowerHtml.includes("this account doesn") ||
|
|
||||||
lowerHtml.includes("account suspended") ||
|
|
||||||
lowerHtml.includes("profile not found")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
lowerHtml.includes("profile not found") ||
|
|
||||||
lowerHtml.includes("page not found") ||
|
|
||||||
lowerHtml.includes("this linkedin profile is unavailable")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkedinFallback(account: ParsedAccount, status?: number) {
|
|
||||||
return Response.json({
|
|
||||||
found: null,
|
|
||||||
verified: false,
|
|
||||||
reason: "unable_to_verify_linkedin",
|
|
||||||
handle: account.handle,
|
|
||||||
status,
|
|
||||||
url: account.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
|
|
||||||
const oembedUrl = new URL("https://publish.twitter.com/oembed")
|
|
||||||
oembedUrl.searchParams.set("url", account.url)
|
|
||||||
|
|
||||||
const response = await fetch(oembedUrl, {
|
|
||||||
signal,
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"User-Agent":
|
|
||||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 404 || response.status === 410) {
|
|
||||||
return Response.json({
|
|
||||||
found: false,
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Unable to verify account",
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
},
|
|
||||||
{ status: 502 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
found: true,
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const source = searchParams.get("source")
|
|
||||||
const value = searchParams.get("value")
|
|
||||||
|
|
||||||
if (source !== "x" && source !== "linkedin") {
|
|
||||||
return Response.json({ error: "Invalid account source" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value?.trim()) {
|
|
||||||
return Response.json({ error: "Missing account value" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = parseAccount(source, value)
|
|
||||||
if (!account) {
|
|
||||||
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 7000)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (source === "x") {
|
|
||||||
return await verifyXAccount(account, controller.signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(account.url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
redirect: "follow",
|
|
||||||
headers: {
|
|
||||||
Accept:
|
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"User-Agent":
|
|
||||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 404 || response.status === 410) {
|
|
||||||
return Response.json({
|
|
||||||
found: false,
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (source === "linkedin") {
|
|
||||||
return linkedinFallback(account, response.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
error: "Unable to verify account",
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
},
|
|
||||||
{ status: 502 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = await response.text()
|
|
||||||
const found = !looksUnavailable(source, html)
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
found,
|
|
||||||
handle: account.handle,
|
|
||||||
status: response.status,
|
|
||||||
url: account.url,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
|
||||||
if (source === "linkedin") {
|
|
||||||
return linkedinFallback(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Account lookup timed out", handle: account.handle },
|
|
||||||
{ status: 504 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Account status lookup failed:", error)
|
|
||||||
if (source === "linkedin") {
|
|
||||||
return linkedinFallback(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Unable to verify account", handle: account.handle },
|
|
||||||
{ status: 502 },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
export interface ExaContentResult {
|
|
||||||
url: string
|
|
||||||
text: string
|
|
||||||
title: string
|
|
||||||
author?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExaApiResponse {
|
|
||||||
results: ExaContentResult[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const exaApiKey = process.env.EXA_API_KEY
|
|
||||||
if (!exaApiKey) {
|
|
||||||
console.error(
|
|
||||||
"EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
if (!exaApiKey) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Content extraction is unavailable" },
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { urls } = await request.json()
|
|
||||||
|
|
||||||
if (!Array.isArray(urls) || urls.length === 0) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid input: urls must be a non-empty array" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!urls.every((url) => typeof url === "string" && url.trim())) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Invalid input: all urls must be non-empty strings" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("https://api.exa.ai/contents", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"x-api-key": exaApiKey,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
urls,
|
|
||||||
text: true,
|
|
||||||
livecrawl: "fallback",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(
|
|
||||||
"Exa API request failed:",
|
|
||||||
response.status,
|
|
||||||
response.statusText,
|
|
||||||
)
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Failed to fetch content from Exa API" },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ExaApiResponse = await response.json()
|
|
||||||
return Response.json({ results: data.results })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Exa API request error:", error)
|
|
||||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { xai } from "@ai-sdk/xai"
|
|
||||||
import { generateText } from "ai"
|
|
||||||
|
|
||||||
interface ResearchRequest {
|
|
||||||
xUrl: string
|
|
||||||
name?: string
|
|
||||||
email?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALLOWED_X_HOSTS: ReadonlySet<string> = new Set([
|
|
||||||
"x.com",
|
|
||||||
"www.x.com",
|
|
||||||
"twitter.com",
|
|
||||||
"www.twitter.com",
|
|
||||||
"mobile.twitter.com",
|
|
||||||
])
|
|
||||||
|
|
||||||
const X_URL_FALLBACK_REGEX =
|
|
||||||
/^(?:https?:\/\/)?(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com|mobile\.twitter\.com)\/([^/\s?#]+)/i
|
|
||||||
|
|
||||||
function isXHost(hostname: string): boolean {
|
|
||||||
return ALLOWED_X_HOSTS.has(hostname.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractHandle(input: string): string {
|
|
||||||
const trimmed = input.trim()
|
|
||||||
if (!trimmed) return ""
|
|
||||||
|
|
||||||
let handle = trimmed.replace(/^@+/, "")
|
|
||||||
const lower = handle.toLowerCase()
|
|
||||||
|
|
||||||
if (lower.includes("x.com") || lower.includes("twitter.com")) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(
|
|
||||||
handle.startsWith("http://") || handle.startsWith("https://")
|
|
||||||
? handle
|
|
||||||
: `https://${handle}`,
|
|
||||||
)
|
|
||||||
handle = isXHost(parsed.hostname)
|
|
||||||
? (parsed.pathname.split("/").filter(Boolean)[0] ?? "")
|
|
||||||
: ""
|
|
||||||
} catch {
|
|
||||||
handle = handle.match(X_URL_FALLBACK_REGEX)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle.replace(/^@+/, "").split(/[/?#]/)[0]?.toLowerCase() ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalPrompt(handle: string, userContext: string) {
|
|
||||||
return `You are researching a user based on their X/Twitter profile to help personalize their experience.
|
|
||||||
|
|
||||||
X Handle: @${handle}${userContext}
|
|
||||||
|
|
||||||
Please analyze this X/Twitter profile and provide a comprehensive but concise summary of the user. Include:
|
|
||||||
- Professional background and current role (if available)
|
|
||||||
- Key interests and topics they engage with
|
|
||||||
- Notable projects, achievements, or affiliations
|
|
||||||
- Their expertise areas
|
|
||||||
- Any other relevant information that helps understand who they are
|
|
||||||
|
|
||||||
Format the response as clear, readable paragraphs. Focus on factual information from their profile. If certain information is not available, skip that section rather than speculating.`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const { xUrl, name, email }: ResearchRequest = await req.json()
|
|
||||||
|
|
||||||
if (!xUrl?.trim()) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "X/Twitter URL or handle is required" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handle = extractHandle(xUrl)
|
|
||||||
|
|
||||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Could not parse a valid X/Twitter handle from the input" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextParts: string[] = []
|
|
||||||
if (name) contextParts.push(`Name: ${name}`)
|
|
||||||
if (email) contextParts.push(`Email: ${email}`)
|
|
||||||
const userContext =
|
|
||||||
contextParts.length > 0
|
|
||||||
? `\n\nAdditional context about the user:\n${contextParts.join("\n")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
const { text } = await generateText({
|
|
||||||
model: xai.responses("grok-4-fast"),
|
|
||||||
prompt: finalPrompt(handle, userContext),
|
|
||||||
tools: {
|
|
||||||
web_search: xai.tools.webSearch(),
|
|
||||||
x_search: xai.tools.xSearch({
|
|
||||||
allowedXHandles: [handle],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response.json({ text })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Research API error:", error)
|
|
||||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from "../connect/page"
|
|
||||||
|
|
@ -1,489 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
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 Image from "next/image"
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
|
||||||
|
|
||||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
|
||||||
|
|
||||||
const API_URL =
|
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
||||||
|
|
||||||
function isValidLocalhostCallback(callback: string): boolean {
|
|
||||||
try {
|
|
||||||
const url = new URL(callback)
|
|
||||||
const isLocalhost =
|
|
||||||
url.hostname === "localhost" || url.hostname === "127.0.0.1"
|
|
||||||
const isHttp = url.protocol === "http:"
|
|
||||||
const isCallbackPath = url.pathname === "/callback"
|
|
||||||
return isLocalhost && isHttp && isCallbackPath
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PluginInfo {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
features: string[]
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|
||||||
claude_code: {
|
|
||||||
name: "Claude Code",
|
|
||||||
description:
|
|
||||||
"Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.",
|
|
||||||
features: [
|
|
||||||
"Auto-recalls relevant context at session start",
|
|
||||||
"Captures important observations from tool usage",
|
|
||||||
"Builds persistent user profile from interactions",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/claude-code.svg",
|
|
||||||
},
|
|
||||||
opencode: {
|
|
||||||
name: "OpenCode",
|
|
||||||
description:
|
|
||||||
"Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.",
|
|
||||||
features: [
|
|
||||||
"Semantic search across previous sessions",
|
|
||||||
"Auto-capture of coding decisions",
|
|
||||||
"Context injection before each prompt",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/opencode.svg",
|
|
||||||
},
|
|
||||||
openclaw: {
|
|
||||||
name: "OpenClaw",
|
|
||||||
description:
|
|
||||||
"Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.",
|
|
||||||
features: [
|
|
||||||
"Cross-channel memory persistence",
|
|
||||||
"Automatic conversation capture",
|
|
||||||
"User profile building across platforms",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/openclaw.svg",
|
|
||||||
},
|
|
||||||
hermes: {
|
|
||||||
name: "Hermes",
|
|
||||||
description: "Memory layer for Hermes agent",
|
|
||||||
features: [
|
|
||||||
"Semantic search across previous sessions",
|
|
||||||
"Auto-capture of conversation context",
|
|
||||||
"Builds persistent user profile from interactions",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/hermes.svg",
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
name: "Cursor",
|
|
||||||
description:
|
|
||||||
"Memory layer for Cursor. Enhances your AI coding assistant with persistent context.",
|
|
||||||
features: [
|
|
||||||
"Remembers coding patterns across sessions",
|
|
||||||
"Auto-capture of project decisions",
|
|
||||||
"Context-aware suggestions",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/cursor.svg",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
name: "OpenAI Codex",
|
|
||||||
description:
|
|
||||||
"Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.",
|
|
||||||
features: [
|
|
||||||
"Auto-recalls relevant context before each prompt",
|
|
||||||
"Captures coding decisions and patterns automatically",
|
|
||||||
"Builds persistent user profile across projects",
|
|
||||||
],
|
|
||||||
icon: "/images/plugins/codex.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginName(client: string): string {
|
|
||||||
return PLUGIN_INFO[client]?.name ?? "External Tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
|
|
||||||
|
|
||||||
const pageWrapperClass =
|
|
||||||
"flex items-center justify-center min-h-screen bg-background p-4"
|
|
||||||
const cardClass = cn(
|
|
||||||
"bg-[#14161A] rounded-[14px] p-6 w-full max-w-[400px]",
|
|
||||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
|
||||||
)
|
|
||||||
|
|
||||||
function AuthConnectContent() {
|
|
||||||
const params = useSearchParams()
|
|
||||||
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
|
|
||||||
|
|
||||||
// Redirect new users (logged in but no organization) to onboarding.
|
|
||||||
// Store the current connect URL so onboarding can redirect back here.
|
|
||||||
const shouldRedirectToOnboarding =
|
|
||||||
!isPending &&
|
|
||||||
!isRestoring &&
|
|
||||||
!!session &&
|
|
||||||
Array.isArray(organizations) &&
|
|
||||||
organizations.length === 0
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPending || isRestoring) return
|
|
||||||
if (!session) return
|
|
||||||
if (organizations === null) return // orgs query still pending
|
|
||||||
if (organizations.length > 0) return // has orgs, nothing to do
|
|
||||||
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(PENDING_CONNECT_URL_KEY, window.location.href)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to access sessionStorage for pending connect URL", e)
|
|
||||||
}
|
|
||||||
router.replace("/onboarding")
|
|
||||||
}, [isPending, isRestoring, session, organizations, router])
|
|
||||||
|
|
||||||
async function handleConnect() {
|
|
||||||
if (!callback) {
|
|
||||||
setStatus("error")
|
|
||||||
setError("Missing callback parameter.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isValidLocalhostCallback(callback)) {
|
|
||||||
setStatus("error")
|
|
||||||
setError("Invalid callback URL.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!session || !org) {
|
|
||||||
setStatus("error")
|
|
||||||
setError(
|
|
||||||
"Your account is not fully set up yet. Please complete onboarding first.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus("creating")
|
|
||||||
const fetchParams = new URLSearchParams({ callback })
|
|
||||||
if (validClient) fetchParams.set("client", validClient)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
throw new Error(errorData.message || "Failed to get API key")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { key: string }
|
|
||||||
setStatus("success")
|
|
||||||
|
|
||||||
const redirectUrl = new URL(callback)
|
|
||||||
redirectUrl.searchParams.set("apikey", data.key)
|
|
||||||
redirectUrl.searchParams.set("api_url", API_URL)
|
|
||||||
window.location.href = redirectUrl.toString()
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to get API key:", err)
|
|
||||||
setStatus("error")
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to get API key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
if (isAuthLoading || shouldRedirectToOnboarding) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
|
||||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
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]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Connect {displayName}
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className={dmSans125ClassName(
|
|
||||||
"text-[13px] text-[#737373] mt-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{pluginInfo?.description ??
|
|
||||||
`Allow ${displayName} to access your Supermemory account.`}
|
|
||||||
</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={handleConnect}
|
|
||||||
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)]",
|
|
||||||
"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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Approve Connection
|
|
||||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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}>
|
|
||||||
<div className={cardClass}>
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<XCircle className="size-10 text-red-400" />
|
|
||||||
<div>
|
|
||||||
<h2
|
|
||||||
className={dmSans125ClassName(
|
|
||||||
"font-semibold text-[18px] text-[#FAFAFA]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Connection failed
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className={dmSans125ClassName(
|
|
||||||
"text-[13px] text-[#737373] mt-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
|
|
||||||
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
|
|
||||||
"text-[13px] font-medium cursor-pointer transition-colors hover:bg-[#1E293B]",
|
|
||||||
dmSans125ClassName(),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="https://app.supermemory.ai"
|
|
||||||
className={dmSans125ClassName(
|
|
||||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Go to app
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
|
||||||
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
|
|
||||||
{status === "creating" && `Connecting ${displayName}…`}
|
|
||||||
{status === "success" &&
|
|
||||||
`Success! Redirecting back to ${displayName}…`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthConnectPage() {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
|
||||||
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AuthConnectContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Suspense } from "react"
|
||||||
import { Toaster } from "@ui/components/sonner"
|
import { Toaster } from "@ui/components/sonner"
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||||
import { ThemeProvider } from "@/lib/theme-provider"
|
import { ThemeProvider } from "@/lib/theme-provider"
|
||||||
|
import { PromoCodeCapture } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
const font = Space_Grotesk({
|
const font = Space_Grotesk({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|
@ -95,6 +96,7 @@ export default function RootLayout({
|
||||||
includeCredentials={true}
|
includeCredentials={true}
|
||||||
headers={{ "X-App-Source": "nova" }}
|
headers={{ "X-App-Source": "nova" }}
|
||||||
>
|
>
|
||||||
|
<PromoCodeCapture />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { connectorPause } from "@/lib/connector-availability"
|
||||||
|
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||||
import type { z } from "zod"
|
import type { z } from "zod"
|
||||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||||
import { cn } from "@lib/utils"
|
import { cn } from "@lib/utils"
|
||||||
|
|
@ -42,6 +44,7 @@ import {
|
||||||
getConnectionSubtitle,
|
getConnectionSubtitle,
|
||||||
} from "@/components/settings/sync-utils"
|
} from "@/components/settings/sync-utils"
|
||||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
type GDriveSyncScope = "scoped" | "full"
|
type GDriveSyncScope = "scoped" | "full"
|
||||||
|
|
||||||
|
|
@ -309,6 +312,7 @@ interface ConnectContentProps {
|
||||||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const { connectorAccess } = useConnectorAccess()
|
const { connectorAccess } = useConnectorAccess()
|
||||||
const [connectingProvider, setConnectingProvider] =
|
const [connectingProvider, setConnectingProvider] =
|
||||||
useState<ConnectorProvider | null>(null)
|
useState<ConnectorProvider | null>(null)
|
||||||
|
|
@ -330,8 +334,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId,
|
planId,
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: window.location.href,
|
successUrl: window.location.href,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
return
|
||||||
|
|
@ -501,7 +507,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const notify = useConnectorNotify()
|
||||||
|
|
||||||
|
// Every connect path funnels here; a `disabled` button would swallow the click.
|
||||||
const handleConnect = (provider: ConnectorProvider) => {
|
const handleConnect = (provider: ConnectorProvider) => {
|
||||||
|
if (connectorPause(provider)) {
|
||||||
|
notify.request(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
setConnectingProvider(provider)
|
setConnectingProvider(provider)
|
||||||
addConnectionMutation.mutate({
|
addConnectionMutation.mutate({
|
||||||
provider,
|
provider,
|
||||||
|
|
@ -544,14 +557,32 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Icon className="size-6 text-[#737373]" />
|
<Icon className="size-6 text-[#737373]" />
|
||||||
<div className="space-y-[6px] flex-1">
|
<div className="space-y-[6px] flex-1">
|
||||||
<p className="text-[16px] font-medium">{config.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-[16px] font-medium">{config.title}</p>
|
||||||
|
{connectorPause(provider) && (
|
||||||
|
<span className="shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]">
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-[16px] text-[#737373]">
|
<p className="text-[16px] text-[#737373]">
|
||||||
{config.description}
|
{config.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{provider === "google-drive" ? (
|
{connectorPause(provider) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => notify.request(provider)}
|
||||||
|
title={connectorPause(provider)?.message}
|
||||||
|
className="bg-[#14161A] text-[#FAFAFA] text-[14px] font-medium px-3 h-8 rounded-md border border-[rgba(82,89,102,0.3)] hover:bg-[#1B1F24] transition-colors"
|
||||||
|
>
|
||||||
|
{notify.isRequested(provider)
|
||||||
|
? "We'll email you"
|
||||||
|
: "Notify me"}
|
||||||
|
</button>
|
||||||
|
) : provider === "google-drive" ? (
|
||||||
<div className="flex items-center rounded-md overflow-hidden">
|
<div className="flex items-center rounded-md overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -717,6 +748,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (connectorPause("google-drive")) {
|
||||||
|
notify.request("google-drive")
|
||||||
|
return
|
||||||
|
}
|
||||||
setConnectingProvider("google-drive")
|
setConnectingProvider("google-drive")
|
||||||
addConnectionMutation.mutate({
|
addConnectionMutation.mutate({
|
||||||
provider: "google-drive",
|
provider: "google-drive",
|
||||||
|
|
@ -737,6 +772,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (connectorPause("google-drive")) {
|
||||||
|
notify.request("google-drive")
|
||||||
|
return
|
||||||
|
}
|
||||||
setConnectingProvider("google-drive")
|
setConnectingProvider("google-drive")
|
||||||
addConnectionMutation.mutate({
|
addConnectionMutation.mutate({
|
||||||
provider: "google-drive",
|
provider: "google-drive",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils"
|
||||||
import { SpaceSelector } from "../space-selector"
|
import { SpaceSelector } from "../space-selector"
|
||||||
import { useIsMobile } from "@hooks/use-mobile"
|
import { useIsMobile } from "@hooks/use-mobile"
|
||||||
import { addDocumentParam } from "@/lib/search-params"
|
import { addDocumentParam } from "@/lib/search-params"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
type TabType = "note" | "link" | "file" | "connect"
|
type TabType = "note" | "link" | "file" | "connect"
|
||||||
|
|
||||||
|
|
@ -153,6 +154,7 @@ export function AddDocument({
|
||||||
})
|
})
|
||||||
|
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const {
|
const {
|
||||||
tokensUsed,
|
tokensUsed,
|
||||||
searchesUsed,
|
searchesUsed,
|
||||||
|
|
@ -342,8 +344,10 @@ export function AddDocument({
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId: "api_pro",
|
planId: "api_pro",
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/settings#account`,
|
successUrl: `${window.location.origin}/settings#account`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
return
|
||||||
|
|
@ -442,8 +446,10 @@ export function AddDocument({
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId: "api_pro",
|
planId: "api_pro",
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/settings#account`,
|
successUrl: `${window.location.origin}/settings#account`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||||
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
||||||
import { DashboardView } from "@/components/dashboard-view"
|
import { DashboardView } from "@/components/dashboard-view"
|
||||||
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
|
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
|
||||||
import { CompanyBrainPromo } from "@/components/company-brain-promo"
|
|
||||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||||
import { MemoriesGrid } from "@/components/memories-grid"
|
import { MemoriesGrid } from "@/components/memories-grid"
|
||||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||||
|
|
@ -43,6 +42,7 @@ import { useIsMobile } from "@hooks/use-mobile"
|
||||||
import { useAuth } from "@lib/auth-context"
|
import { useAuth } from "@lib/auth-context"
|
||||||
import { useProject } from "@/stores"
|
import { useProject } from "@/stores"
|
||||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||||
|
import { isConnectorPaused } from "@/lib/connector-availability"
|
||||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||||
import {
|
import {
|
||||||
useQuickNoteDraftReset,
|
useQuickNoteDraftReset,
|
||||||
|
|
@ -603,6 +603,10 @@ export function AppExperience() {
|
||||||
|
|
||||||
const handleOpenIntegrations = useCallback(
|
const handleOpenIntegrations = useCallback(
|
||||||
(integration?: IntegrationParamValue) => {
|
(integration?: IntegrationParamValue) => {
|
||||||
|
if (integration && isConnectorPaused(integration)) {
|
||||||
|
void setViewMode("integrations")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (integration === "notion" || integration === "google-drive") {
|
if (integration === "notion" || integration === "google-drive") {
|
||||||
void setAddDoc("connect")
|
void setAddDoc("connect")
|
||||||
return
|
return
|
||||||
|
|
@ -825,7 +829,6 @@ export function AppExperience() {
|
||||||
) : (
|
) : (
|
||||||
<DashboardView
|
<DashboardView
|
||||||
spaceLabel={dashboardSpaceLabel}
|
spaceLabel={dashboardSpaceLabel}
|
||||||
headerNotice={<CompanyBrainPromo />}
|
|
||||||
highlights={highlightsData?.highlights ?? []}
|
highlights={highlightsData?.highlights ?? []}
|
||||||
isLoadingHighlights={isLoadingHighlights}
|
isLoadingHighlights={isLoadingHighlights}
|
||||||
onAddMemory={handleAddMemory}
|
onAddMemory={handleAddMemory}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { cn } from "@lib/utils"
|
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"
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
|
||||||
export function SlackMark({ className }: { className?: string }) {
|
export function SlackMark({ className }: { className?: string }) {
|
||||||
|
|
@ -99,6 +99,8 @@ export function brainConnectorIcon(
|
||||||
className = "size-[18px]",
|
className = "size-[18px]",
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
switch (slug) {
|
switch (slug) {
|
||||||
|
case "google-drive":
|
||||||
|
return <GoogleDrive className={className} />
|
||||||
case "gmail":
|
case "gmail":
|
||||||
return <Gmail className={className} />
|
return <Gmail className={className} />
|
||||||
case "github":
|
case "github":
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
||||||
import { useQueryState } from "nuqs"
|
import { useQueryState } from "nuqs"
|
||||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||||
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
||||||
import { TrialSetupBanner } from "@/components/trial-setup-banner"
|
|
||||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||||
import { dmSans125ClassName } from "@/lib/fonts"
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
import { useViewMode } from "@/lib/view-mode-context"
|
import { useViewMode } from "@/lib/view-mode-context"
|
||||||
|
|
@ -16,7 +15,6 @@ import {
|
||||||
AskInSlackCard,
|
AskInSlackCard,
|
||||||
CONNECT_TOOLS_CARD_ID,
|
CONNECT_TOOLS_CARD_ID,
|
||||||
ConnectToolsCard,
|
ConnectToolsCard,
|
||||||
SlackBanner,
|
|
||||||
useConnectionsBoard,
|
useConnectionsBoard,
|
||||||
} from "./connections-board"
|
} from "./connections-board"
|
||||||
|
|
||||||
|
|
@ -172,7 +170,6 @@ export function BrainHomeView() {
|
||||||
const o = useBrainOverview()
|
const o = useBrainOverview()
|
||||||
const trial = useBrainTrial()
|
const trial = useBrainTrial()
|
||||||
const board = useConnectionsBoard()
|
const board = useConnectionsBoard()
|
||||||
const { needsSetup } = useTrialStatus()
|
|
||||||
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
||||||
const milestones = [
|
const milestones = [
|
||||||
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
||||||
|
|
@ -189,7 +186,6 @@ export function BrainHomeView() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||||
<TrialSetupBanner />
|
|
||||||
<StatsRow
|
<StatsRow
|
||||||
memories={o.memoriesCount}
|
memories={o.memoriesCount}
|
||||||
connected={o.connectedCount}
|
connected={o.connectedCount}
|
||||||
|
|
@ -199,7 +195,6 @@ export function BrainHomeView() {
|
||||||
setupTotal={milestonesTotal}
|
setupTotal={milestonesTotal}
|
||||||
lastUpdatedAt={o.lastUpdatedAt}
|
lastUpdatedAt={o.lastUpdatedAt}
|
||||||
/>
|
/>
|
||||||
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
|
|
||||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
<div className="min-w-0 space-y-6">
|
<div className="min-w-0 space-y-6">
|
||||||
{board.showBoard && <ConnectToolsCard board={board} />}
|
{board.showBoard && <ConnectToolsCard board={board} />}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ const BACKEND =
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||||
|
|
||||||
const cardStyle = {
|
export const cardStyle = {
|
||||||
boxShadow:
|
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",
|
"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",
|
||||||
}
|
}
|
||||||
const tileStyle = {
|
export const tileStyle = {
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||||
}
|
}
|
||||||
|
|
@ -442,71 +442,3 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlackBanner() {
|
|
||||||
const { needsSetup } = useTrialStatus()
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
|
||||||
style={cardStyle}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute -top-px right-8 left-8 h-px"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-3 sm:gap-4">
|
|
||||||
<div className="flex min-w-0 items-center gap-3 sm:gap-3.5">
|
|
||||||
<div
|
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F] sm:size-12"
|
|
||||||
style={tileStyle}
|
|
||||||
>
|
|
||||||
<SlackMark className="size-6 sm:size-7" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"truncate text-[15px] font-semibold leading-tight text-[#fafafa] sm:text-[16px]",
|
|
||||||
dmSans125ClassName(),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="sm:hidden">Slack agent</span>
|
|
||||||
<span className="hidden sm:inline">Company Brain in Slack</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 truncate text-[12px] font-medium leading-[1.45] text-[#737373] sm:mt-0.5 sm:text-[13px] sm:leading-[1.5]">
|
|
||||||
<span className="sm:hidden">
|
|
||||||
Ask <span className="text-[#A1A1AA]">@supermemory</span> from
|
|
||||||
any channel.
|
|
||||||
</span>
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
Install Supermemory so your team can{" "}
|
|
||||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
|
||||||
channel.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
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]"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { FeedbackModal } from "@/components/feedback-modal"
|
||||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||||
import { SlackMark } from "@/components/brain-connector-icons"
|
import { SlackMark } from "@/components/brain-connector-icons"
|
||||||
import { BrainTrialPill } from "@/components/brain-trial-pill"
|
import { BrainTrialPill } from "@/components/brain-trial-pill"
|
||||||
|
import { SetupCallLink } from "@/components/setup-call-link"
|
||||||
import { GraphIcon } from "@/components/integration-icons"
|
import { GraphIcon } from "@/components/integration-icons"
|
||||||
import { SpaceSelector } from "@/components/space-selector"
|
import { SpaceSelector } from "@/components/space-selector"
|
||||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||||
|
|
@ -461,7 +462,7 @@ export function CompanyBrainHeader({
|
||||||
<Sun className="size-4 text-[#737373]" />
|
<Sun className="size-4 text-[#737373]" />
|
||||||
Integrations
|
Integrations
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{slackConnected ? (
|
{slackConnected && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={goConfigure}
|
onClick={goConfigure}
|
||||||
className={menuItemClass}
|
className={menuItemClass}
|
||||||
|
|
@ -469,13 +470,6 @@ export function CompanyBrainHeader({
|
||||||
<SlackMark className="size-4" />
|
<SlackMark className="size-4" />
|
||||||
Slack connected
|
Slack connected
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
|
||||||
<DropdownMenuItem asChild className={menuItemClass}>
|
|
||||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
|
||||||
<SlackMark className="size-4" />
|
|
||||||
Add to Slack
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||||
{canInvite && (
|
{canInvite && (
|
||||||
|
|
@ -538,29 +532,7 @@ export function CompanyBrainHeader({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{canInvite && (
|
<SetupCallLink />
|
||||||
<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={handleInvite}
|
|
||||||
aria-label="Invite teammates"
|
|
||||||
>
|
|
||||||
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
|
|
||||||
<span className="max-lg:sr-only">Invite</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
|
||||||
Invite teammates
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -603,28 +575,9 @@ function SlackNavButton({
|
||||||
active: boolean
|
active: boolean
|
||||||
onManage: () => void
|
onManage: () => void
|
||||||
}) {
|
}) {
|
||||||
const label = connected
|
const label = `Slack${teamName ? ` · ${teamName}` : ""}`
|
||||||
? `Slack${teamName ? ` · ${teamName}` : ""}`
|
|
||||||
: "Add to Slack"
|
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) return null
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
|
||||||
aria-label="Add to Slack"
|
|
||||||
className={circleNavClass(false)}
|
|
||||||
>
|
|
||||||
<SlackMark className="size-4" />
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
|
||||||
Add to Slack
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ArrowRight, XIcon } from "lucide-react"
|
|
||||||
import { Logo } from "@ui/assets/Logo"
|
|
||||||
import { Button } from "@repo/ui/components/button"
|
|
||||||
import { cn } from "@lib/utils"
|
|
||||||
import { analytics } from "@/lib/analytics"
|
|
||||||
import { dmSansClassName } from "@/lib/fonts"
|
|
||||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
|
||||||
|
|
||||||
const DISMISS_KEY = "supermemory-company-brain-promo-dismissed-v1"
|
|
||||||
|
|
||||||
export function CompanyBrainPromo() {
|
|
||||||
const router = useRouter()
|
|
||||||
const hasCompanyBrain = useHasCompanyBrain()
|
|
||||||
const [dismissed, setDismissed] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasCompanyBrain) return
|
|
||||||
try {
|
|
||||||
setDismissed(localStorage.getItem(DISMISS_KEY) === "1")
|
|
||||||
} catch {
|
|
||||||
setDismissed(false)
|
|
||||||
}
|
|
||||||
}, [hasCompanyBrain])
|
|
||||||
|
|
||||||
const visible = !hasCompanyBrain && !dismissed
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) analytics.companyBrainPromoSeen()
|
|
||||||
}, [visible])
|
|
||||||
|
|
||||||
if (!visible) return null
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
setDismissed(true)
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DISMISS_KEY, "1")
|
|
||||||
} catch {}
|
|
||||||
analytics.companyBrainPromoDismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
|
|
||||||
"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]">
|
|
||||||
<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>
|
|
||||||
<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]"
|
|
||||||
>
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -31,8 +31,6 @@ import {
|
||||||
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
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 { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||||
import { analytics } from "@/lib/analytics"
|
import { analytics } from "@/lib/analytics"
|
||||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||||
|
|
@ -1345,8 +1343,6 @@ export function DashboardView({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
<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}
|
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
|
||||||
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)]"
|
||||||
|
|
@ -61,7 +61,6 @@ export function FullscreenNoteModal({
|
||||||
|
|
||||||
const handleContentChange = useCallback(
|
const handleContentChange = useCallback(
|
||||||
(newContent: string) => {
|
(newContent: string) => {
|
||||||
console.log("handleContentChange", newContent)
|
|
||||||
setContent(newContent)
|
setContent(newContent)
|
||||||
setDraft(newContent)
|
setDraft(newContent)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useCustomer } from "autumn-js/react"
|
import { useCustomer } from "autumn-js/react"
|
||||||
import { cn } from "@lib/utils"
|
import { cn } from "@lib/utils"
|
||||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
import { SectionRail } from "@/components/directory/section-rail"
|
||||||
import { $fetch } from "@lib/api"
|
import { $fetch } from "@lib/api"
|
||||||
import { authClient } from "@lib/auth"
|
import { authClient } from "@lib/auth"
|
||||||
import { useAuth } from "@lib/auth-context"
|
import { useAuth } from "@lib/auth-context"
|
||||||
|
|
@ -36,13 +37,17 @@ import {
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Info,
|
Info,
|
||||||
|
Bell,
|
||||||
Loader,
|
Loader,
|
||||||
|
Pause,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||||
|
import { connectorPause } from "@/lib/connector-availability"
|
||||||
|
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||||
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
||||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||||
|
|
@ -71,8 +76,14 @@ import {
|
||||||
isFreeTierPlugin,
|
isFreeTierPlugin,
|
||||||
normalizePluginClientId,
|
normalizePluginClientId,
|
||||||
type InstallStep,
|
type InstallStep,
|
||||||
|
type PluginInfo,
|
||||||
} from "@/lib/plugin-catalog"
|
} from "@/lib/plugin-catalog"
|
||||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
import {
|
||||||
|
CopyButton,
|
||||||
|
INSET,
|
||||||
|
InstallSteps,
|
||||||
|
PillButton,
|
||||||
|
} from "./integrations/install-steps"
|
||||||
import {
|
import {
|
||||||
ShortcutsConnectButtons,
|
ShortcutsConnectButtons,
|
||||||
useShortcutsConnect,
|
useShortcutsConnect,
|
||||||
|
|
@ -80,6 +91,7 @@ import {
|
||||||
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
|
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
|
||||||
import { GranolaConnectModal } from "./granola-connect-modal"
|
import { GranolaConnectModal } from "./granola-connect-modal"
|
||||||
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
|
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||||
|
|
||||||
|
|
@ -536,13 +548,13 @@ const SECTIONS: Array<{
|
||||||
action: { type: "external", href: POKE_RECIPE_URL },
|
action: { type: "external", href: POKE_RECIPE_URL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "client",
|
kind: "import",
|
||||||
id: "shortcuts",
|
id: "x-bookmarks",
|
||||||
name: "Apple Shortcuts",
|
name: "Import X bookmarks",
|
||||||
tagline: "Add memories from iPhone, iPad or Mac",
|
tagline: "Turn your X/Twitter bookmarks into memories",
|
||||||
simpleTitle: "Save anything from your phone or Mac",
|
simpleTitle: "Turn your X bookmarks into memory",
|
||||||
icon: <AppleShortcutsIcon />,
|
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
||||||
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
viewMode: "import" as ViewParamValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "client",
|
kind: "client",
|
||||||
|
|
@ -555,13 +567,13 @@ const SECTIONS: Array<{
|
||||||
dev: true,
|
dev: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "import",
|
kind: "client",
|
||||||
id: "x-bookmarks",
|
id: "shortcuts",
|
||||||
name: "Import X bookmarks",
|
name: "Apple Shortcuts",
|
||||||
tagline: "Turn your X/Twitter bookmarks into memories",
|
tagline: "Add memories from iPhone, iPad or Mac",
|
||||||
simpleTitle: "Turn your X bookmarks into memory",
|
simpleTitle: "Save anything from your phone or Mac",
|
||||||
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
icon: <AppleShortcutsIcon />,
|
||||||
viewMode: "import" as ViewParamValue,
|
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -591,6 +603,52 @@ export function DetailWrapper({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotifyMeButton({
|
||||||
|
provider,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
provider: string
|
||||||
|
title?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
const { isRequested, request } = useConnectorNotify()
|
||||||
|
const requested = isRequested(provider)
|
||||||
|
return (
|
||||||
|
<PillButton
|
||||||
|
className={requested ? "cursor-default opacity-60" : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
onClick?.()
|
||||||
|
if (!requested) request(provider)
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{requested ? (
|
||||||
|
<>
|
||||||
|
<Check className="size-3.5" /> We'll email you
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Bell className="size-3.5" /> Notify me
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PillButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PausedChip() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Paused
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
|
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
@ -637,6 +695,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 = {
|
type InfoUseCase = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -2067,6 +2325,8 @@ function ItemCard({
|
||||||
docsUrl,
|
docsUrl,
|
||||||
leftIndicator,
|
leftIndicator,
|
||||||
statusSlot,
|
statusSlot,
|
||||||
|
layoutClassName,
|
||||||
|
paused,
|
||||||
}: {
|
}: {
|
||||||
actionSlot: ReactNode
|
actionSlot: ReactNode
|
||||||
infoActionSlot?: ReactNode
|
infoActionSlot?: ReactNode
|
||||||
|
|
@ -2081,6 +2341,8 @@ function ItemCard({
|
||||||
docsUrl?: string
|
docsUrl?: string
|
||||||
leftIndicator?: ReactNode
|
leftIndicator?: ReactNode
|
||||||
statusSlot?: ReactNode
|
statusSlot?: ReactNode
|
||||||
|
layoutClassName?: string
|
||||||
|
paused?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [infoOpen, setInfoOpen] = useState(false)
|
const [infoOpen, setInfoOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
|
|
@ -2098,6 +2360,9 @@ function ItemCard({
|
||||||
className={cn(
|
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",
|
"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)]",
|
"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)} />
|
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
|
||||||
|
|
@ -2115,7 +2380,12 @@ function ItemCard({
|
||||||
<div className="flex shrink-0 items-start justify-between gap-2">
|
<div className="flex shrink-0 items-start justify-between gap-2">
|
||||||
<IconBox>{icon}</IconBox>
|
<IconBox>{icon}</IconBox>
|
||||||
</div>
|
</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="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
{leftIndicator}
|
{leftIndicator}
|
||||||
|
|
@ -2127,7 +2397,8 @@ function ItemCard({
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
{isNew && <NewChip />}
|
{paused && <PausedChip />}
|
||||||
|
{isNew && !paused && <NewChip />}
|
||||||
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
|
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
|
|
@ -2139,7 +2410,13 @@ function ItemCard({
|
||||||
{tagline}
|
{tagline}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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. */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
|
||||||
<div
|
<div
|
||||||
className="hidden min-w-0 flex-1 sm:flex"
|
className="hidden min-w-0 flex-1 sm:flex"
|
||||||
|
|
@ -2150,7 +2427,11 @@ function ItemCard({
|
||||||
</div>
|
</div>
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
|
||||||
<div
|
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|
@ -2453,95 +2734,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({
|
export function IntegrationsView({
|
||||||
publicMode = false,
|
publicMode = false,
|
||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
|
|
@ -2555,6 +2747,7 @@ export function IntegrationsView({
|
||||||
const { allProjects } = useContainerTags()
|
const { allProjects } = useContainerTags()
|
||||||
const shortcutsConnect = useShortcutsConnect()
|
const shortcutsConnect = useShortcutsConnect()
|
||||||
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
||||||
|
const promoCode = usePromoCode()
|
||||||
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
||||||
// stay on hasProProduct. See useConnectorAccess.
|
// stay on hasProProduct. See useConnectorAccess.
|
||||||
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
||||||
|
|
@ -2566,18 +2759,21 @@ export function IntegrationsView({
|
||||||
const [connectingProvider, setConnectingProvider] =
|
const [connectingProvider, setConnectingProvider] =
|
||||||
useState<ConnectorProvider | null>(null)
|
useState<ConnectorProvider | null>(null)
|
||||||
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
||||||
|
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
|
||||||
const [newKey, setNewKey] = useState<{
|
const [newKey, setNewKey] = useState<{
|
||||||
open: boolean
|
open: boolean
|
||||||
key: string
|
key: string
|
||||||
pluginId: string | null
|
pluginId: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}>({ open: false, key: "", pluginId: null, loading: false })
|
}>({ 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>(
|
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: pluginsData } = useQuery({
|
const { data: pluginsData } = useQuery({
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|
@ -2747,11 +2943,6 @@ export function IntegrationsView({
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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 {
|
const errorData = (await res.json().catch(() => ({}))) as {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
@ -2761,12 +2952,7 @@ export function IntegrationsView({
|
||||||
},
|
},
|
||||||
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
|
setNewKey((s) => ({ ...s, loading: false }))
|
||||||
setNewKey((s) =>
|
|
||||||
s.loading
|
|
||||||
? { open: false, key: "", pluginId: null, loading: false }
|
|
||||||
: s,
|
|
||||||
)
|
|
||||||
toast.error("Failed to connect plugin", {
|
toast.error("Failed to connect plugin", {
|
||||||
description: err instanceof Error ? err.message : "Unknown error",
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
})
|
})
|
||||||
|
|
@ -2776,10 +2962,32 @@ export function IntegrationsView({
|
||||||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||||
},
|
},
|
||||||
onSuccess: (data, pluginId) => {
|
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({
|
const addConnectionMutation = useMutation({
|
||||||
mutationFn: async (provider: ConnectorProvider) => {
|
mutationFn: async (provider: ConnectorProvider) => {
|
||||||
const response = await $fetch("@post/connections/:provider", {
|
const response = await $fetch("@post/connections/:provider", {
|
||||||
|
|
@ -2824,14 +3032,22 @@ export function IntegrationsView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePausedConnector = useCallback((provider: string) => {
|
||||||
|
const pause = connectorPause(provider)
|
||||||
|
if (!pause) return
|
||||||
|
toast.info(pause.message)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleUpgrade = useCallback(
|
const handleUpgrade = useCallback(
|
||||||
async (planId?: unknown) => {
|
async (planId?: unknown) => {
|
||||||
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId: checkoutPlanId,
|
planId: checkoutPlanId,
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/integrations`,
|
successUrl: `${window.location.origin}/integrations`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
return
|
||||||
|
|
@ -2842,7 +3058,7 @@ export function IntegrationsView({
|
||||||
toast.error("Failed to start checkout. Please try again.")
|
toast.error("Failed to start checkout. Please try again.")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[autumn],
|
[autumn, promoCode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const redirectToLogin = useCallback(() => {
|
const redirectToLogin = useCallback(() => {
|
||||||
|
|
@ -2909,10 +3125,7 @@ export function IntegrationsView({
|
||||||
void setConnectTarget(null)
|
void setConnectTarget(null)
|
||||||
handleUpgrade("api_pro")
|
handleUpgrade("api_pro")
|
||||||
} else {
|
} else {
|
||||||
// Open instantly; the key fills in on mint. The ?connect param stays the source
|
openPluginSetup(target)
|
||||||
// of truth until the modal closes.
|
|
||||||
setNewKey({ open: true, key: "", pluginId: target, loading: true })
|
|
||||||
createPluginKeyMutation.mutate(target)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2930,6 +3143,10 @@ export function IntegrationsView({
|
||||||
if (["notion", "google-drive", "onedrive"].includes(target)) {
|
if (["notion", "google-drive", "onedrive"].includes(target)) {
|
||||||
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
|
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
|
||||||
void setConnectTarget(null)
|
void setConnectTarget(null)
|
||||||
|
if (connectorPause(target)) {
|
||||||
|
handlePausedConnector(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
void setAddDoc("connect")
|
void setAddDoc("connect")
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
@ -2947,8 +3164,9 @@ export function IntegrationsView({
|
||||||
redirectToLogin,
|
redirectToLogin,
|
||||||
setConnectTarget,
|
setConnectTarget,
|
||||||
setAddDoc,
|
setAddDoc,
|
||||||
createPluginKeyMutation,
|
|
||||||
handleUpgrade,
|
handleUpgrade,
|
||||||
|
openPluginSetup,
|
||||||
|
handlePausedConnector,
|
||||||
])
|
])
|
||||||
|
|
||||||
const closeMcpModal = () => {
|
const closeMcpModal = () => {
|
||||||
|
|
@ -3263,7 +3481,7 @@ export function IntegrationsView({
|
||||||
handleUpgrade("api_pro")
|
handleUpgrade("api_pro")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createPluginKeyMutation.mutate("claude_code")
|
openPluginSetup("claude_code")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -3341,7 +3559,7 @@ export function IntegrationsView({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trackCard(item)
|
trackCard(item)
|
||||||
createPluginKeyMutation.mutate(item.pluginId)
|
openPluginSetup(item.pluginId)
|
||||||
}}
|
}}
|
||||||
disabled={!!connectingPlugin}
|
disabled={!!connectingPlugin}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -3362,12 +3580,7 @@ export function IntegrationsView({
|
||||||
<FinishSetupButton
|
<FinishSetupButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackCard(item)
|
trackCard(item)
|
||||||
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
|
openPluginSetup(item.pluginId)
|
||||||
if (connectingPlugin) return
|
|
||||||
createPluginKeyMutation.mutate(item.pluginId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFinishSetupPluginId(item.pluginId)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -3384,7 +3597,7 @@ export function IntegrationsView({
|
||||||
<PillButton
|
<PillButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackCard(item)
|
trackCard(item)
|
||||||
createPluginKeyMutation.mutate(item.pluginId)
|
openPluginSetup(item.pluginId)
|
||||||
}}
|
}}
|
||||||
disabled={!!connectingPlugin}
|
disabled={!!connectingPlugin}
|
||||||
>
|
>
|
||||||
|
|
@ -3402,15 +3615,20 @@ export function IntegrationsView({
|
||||||
const count = connectionsByProvider[item.provider].length
|
const count = connectionsByProvider[item.provider].length
|
||||||
const isGranola = item.provider === "granola"
|
const isGranola = item.provider === "granola"
|
||||||
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
|
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
|
||||||
|
const pause = connectorPause(item.provider)
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Add another knowledge source"
|
aria-label={pause ? "Paused" : "Add another knowledge source"}
|
||||||
title="Add another knowledge source"
|
title={pause ? pause.message : "Add another knowledge source"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackCard(item)
|
trackCard(item)
|
||||||
|
if (pause) {
|
||||||
|
handlePausedConnector(item.provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isGranola) {
|
if (isGranola) {
|
||||||
if (!connectorAccess) {
|
if (!connectorAccess) {
|
||||||
handleUpgrade("api_pro")
|
handleUpgrade("api_pro")
|
||||||
|
|
@ -3424,13 +3642,27 @@ export function IntegrationsView({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
|
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
|
||||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||||
|
pause && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
{pause ? (
|
||||||
|
<Pause className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Plus className="size-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (pause) {
|
||||||
|
return (
|
||||||
|
<NotifyMeButton
|
||||||
|
onClick={() => trackCard(item)}
|
||||||
|
provider={item.provider}
|
||||||
|
title={pause.message}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (needsPlanUpgrade) {
|
if (needsPlanUpgrade) {
|
||||||
return (
|
return (
|
||||||
<PillButton onClick={() => handleUpgrade("api_pro")}>
|
<PillButton onClick={() => handleUpgrade("api_pro")}>
|
||||||
|
|
@ -3554,7 +3786,7 @@ export function IntegrationsView({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trackCard(item)
|
trackCard(item)
|
||||||
createPluginKeyMutation.mutate(item.pluginId)
|
openPluginSetup(item.pluginId)
|
||||||
}}
|
}}
|
||||||
disabled={!!connectingPlugin}
|
disabled={!!connectingPlugin}
|
||||||
>
|
>
|
||||||
|
|
@ -3629,7 +3861,7 @@ export function IntegrationsView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderItemCard = (item: Item) => (
|
const renderItemCard = (item: Item, layoutClassName?: string) => (
|
||||||
<ItemCard
|
<ItemCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
actionSlot={renderRight(item)}
|
actionSlot={renderRight(item)}
|
||||||
|
|
@ -3645,6 +3877,8 @@ export function IntegrationsView({
|
||||||
docsUrl={item.docsUrl}
|
docsUrl={item.docsUrl}
|
||||||
leftIndicator={renderLeftIndicator(item)}
|
leftIndicator={renderLeftIndicator(item)}
|
||||||
statusSlot={renderStatus(item)}
|
statusSlot={renderStatus(item)}
|
||||||
|
layoutClassName={layoutClassName}
|
||||||
|
paused={item.kind === "connector" && !!connectorPause(item.provider)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -3666,10 +3900,6 @@ export function IntegrationsView({
|
||||||
!isAutumnLoading &&
|
!isAutumnLoading &&
|
||||||
!hasProProduct &&
|
!hasProProduct &&
|
||||||
!isFreeTierPlugin(connectedPluginId)
|
!isFreeTierPlugin(connectedPluginId)
|
||||||
const finishSetupPlugin = finishSetupPluginId
|
|
||||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
|
||||||
: undefined
|
|
||||||
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
|
|
||||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||||
|
|
@ -3754,7 +3984,14 @@ export function IntegrationsView({
|
||||||
</p>
|
</p>
|
||||||
) : q || category !== "all" ? (
|
) : q || category !== "all" ? (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
@ -3767,6 +4004,23 @@ export function IntegrationsView({
|
||||||
<SectionRail
|
<SectionRail
|
||||||
key={cat}
|
key={cat}
|
||||||
label={CATEGORY_LABEL[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={
|
headerSlot={
|
||||||
cat === "ai-clients" && activeMcpKey ? (
|
cat === "ai-clients" && activeMcpKey ? (
|
||||||
<McpConnectedPill
|
<McpConnectedPill
|
||||||
|
|
@ -3812,6 +4066,11 @@ export function IntegrationsView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PluginCommandsDialog
|
||||||
|
open={pluginCommandsOpen}
|
||||||
|
onOpenChange={setPluginCommandsOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={newKey.open}
|
open={newKey.open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -3821,7 +4080,10 @@ export function IntegrationsView({
|
||||||
pluginId: open ? s.pluginId : null,
|
pluginId: open ? s.pluginId : null,
|
||||||
loading: open ? s.loading : false,
|
loading: open ? s.loading : false,
|
||||||
}))
|
}))
|
||||||
if (!open) void setConnectTarget(null)
|
if (!open) {
|
||||||
|
setPluginSetupTab("agent")
|
||||||
|
void setConnectTarget(null)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|
@ -3854,9 +4116,11 @@ export function IntegrationsView({
|
||||||
Set up {dialogPlugin?.name ?? "your plugin"}
|
Set up {dialogPlugin?.name ?? "your plugin"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||||
{newKey.loading
|
{pluginSetupTab === "agent"
|
||||||
? "Generating your key…"
|
? "Copy this prompt into your coding agent."
|
||||||
: "Copy your key and run these steps to finish."}
|
: newKey.loading
|
||||||
|
? "Generating your key…"
|
||||||
|
: "Follow these steps to finish manually."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
|
@ -3889,17 +4153,30 @@ export function IntegrationsView({
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
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]">
|
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
|
||||||
<Loader className="size-4 animate-spin" />
|
<Loader className="size-4 animate-spin" />
|
||||||
Generating your key…
|
Generating your key…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : newKey.key ? (
|
||||||
<InstallSteps steps={setupSteps} apiKey={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3913,6 +4190,7 @@ export function IntegrationsView({
|
||||||
pluginId: null,
|
pluginId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
})
|
})
|
||||||
|
setPluginSetupTab("agent")
|
||||||
void setConnectTarget(null)
|
void setConnectTarget(null)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -4051,7 +4329,7 @@ export function IntegrationsView({
|
||||||
if (!connectedPluginId) return
|
if (!connectedPluginId) return
|
||||||
const pluginId = connectedPluginId
|
const pluginId = connectedPluginId
|
||||||
setConnectedPluginId(null)
|
setConnectedPluginId(null)
|
||||||
createPluginKeyMutation.mutate(pluginId)
|
openPluginSetup(pluginId)
|
||||||
}}
|
}}
|
||||||
disabled={!!connectingPlugin}
|
disabled={!!connectingPlugin}
|
||||||
>
|
>
|
||||||
|
|
@ -4082,91 +4360,6 @@ export function IntegrationsView({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<Dialog
|
||||||
open={mcpModalOpen}
|
open={mcpModalOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,22 @@ export function PillButton({
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
type = "button",
|
type = "button",
|
||||||
|
className,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
type?: "button" | "submit"
|
type?: "button" | "submit"
|
||||||
|
className?: string
|
||||||
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
dmSans125ClassName(),
|
dmSans125ClassName(),
|
||||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||||
|
|
@ -34,6 +39,7 @@ export function PillButton({
|
||||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||||
"cursor-pointer transition-opacity hover:opacity-80",
|
"cursor-pointer transition-opacity hover:opacity-80",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
type PluginInfo,
|
type PluginInfo,
|
||||||
} from "@/lib/plugin-catalog"
|
} from "@/lib/plugin-catalog"
|
||||||
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
interface ConnectedPlugin {
|
interface ConnectedPlugin {
|
||||||
id: string
|
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() {
|
export function PluginsDetail() {
|
||||||
const { org } = useAuth()
|
const { org } = useAuth()
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
|
||||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
|
|
@ -572,11 +536,6 @@ export function PluginsDetail() {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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 {
|
const errorData = (await res.json().catch(() => ({}))) as {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
@ -613,8 +572,10 @@ export function PluginsDetail() {
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId: "api_pro",
|
planId: "api_pro",
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/integrations`,
|
successUrl: `${window.location.origin}/integrations`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
return
|
||||||
|
|
@ -635,17 +596,12 @@ export function PluginsDetail() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const visibleRows = useMemo(() => {
|
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).
|
// Connected plugins float to the top (stable within each group).
|
||||||
return [...filtered].sort(
|
return [...catalogRows].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
|
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
|
||||||
)
|
)
|
||||||
}, [catalogRows, tierFilter, connectedPluginIds])
|
}, [catalogRows, connectedPluginIds])
|
||||||
|
|
||||||
const dialogPlugin = newKey.pluginId
|
const dialogPlugin = newKey.pluginId
|
||||||
? PLUGIN_CATALOG[newKey.pluginId]
|
? PLUGIN_CATALOG[newKey.pluginId]
|
||||||
|
|
@ -684,12 +640,7 @@ export function PluginsDetail() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<SectionHeader>Plugins</SectionHeader>
|
||||||
<SectionHeader>Plugins</SectionHeader>
|
|
||||||
{catalogRows.length > 0 && (
|
|
||||||
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{visibleRows.map((pluginId) => {
|
{visibleRows.map((pluginId) => {
|
||||||
const plugin = PLUGIN_CATALOG[pluginId]
|
const plugin = PLUGIN_CATALOG[pluginId]
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
|
||||||
}) {
|
}) {
|
||||||
const { connect, isPending, pendingType } = controller
|
const { connect, isPending, pendingType } = controller
|
||||||
return (
|
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
|
<PillButton
|
||||||
className="h-9 flex-none"
|
className="h-9 flex-none"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { LogoFull } from "@ui/assets/Logo"
|
import { LogoFull } from "@ui/assets/Logo"
|
||||||
import { Button } from "@ui/components/button"
|
import { Button } from "@ui/components/button"
|
||||||
import { Input } from "@ui/components/input"
|
import { Input } from "@ui/components/input"
|
||||||
|
import { useAuth } from "@lib/auth-context"
|
||||||
import { cn } from "@lib/utils"
|
import { cn } from "@lib/utils"
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||||
|
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
|
||||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||||
import {
|
import {
|
||||||
type ResearchEvent,
|
type ResearchEvent,
|
||||||
|
|
@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({
|
||||||
setPhase("trial")
|
setPhase("trial")
|
||||||
analytics.brainTrialCardViewed()
|
analytics.brainTrialCardViewed()
|
||||||
}, [needsSetup, phase])
|
}, [needsSetup, phase])
|
||||||
|
const { org } = useAuth()
|
||||||
const [domain, setDomain] = useState(initialDomain)
|
const [domain, setDomain] = useState(initialDomain)
|
||||||
const [organizationChoices, setOrganizationChoices] = useState<
|
const [organizationChoices, setOrganizationChoices] = useState<
|
||||||
CompanyBrainOrganizationChoice[] | null
|
CompanyBrainOrganizationChoice[] | null
|
||||||
>(null)
|
>(null)
|
||||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
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 queryClient = useQueryClient()
|
||||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||||
const researchDone = researchStatus === "done"
|
const researchDone = researchStatus === "done"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useResearchStatus } from "@/hooks/use-research-status"
|
import { useResearchStatus } from "@/hooks/use-research-status"
|
||||||
import { dmSans125ClassName } from "@/lib/fonts"
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
import { SetupCallButton } from "./setup-call-button"
|
||||||
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
||||||
|
|
||||||
const BACKEND =
|
const BACKEND =
|
||||||
|
|
@ -480,6 +481,14 @@ export function ResearchActionRail({
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-white/[0.06] pt-5">
|
||||||
|
<p className="mb-3 text-[12px] font-medium leading-[1.5] text-[#525D6E]">
|
||||||
|
Want us to wire it up live? Slack, connectors, plugins, and a
|
||||||
|
working walkthrough.
|
||||||
|
</p>
|
||||||
|
<SetupCallButton className="w-full" surface="research_rail" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
32
apps/web/components/onboarding-brain/setup-call-button.tsx
Normal file
32
apps/web/components/onboarding-brain/setup-call-button.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@lib/utils"
|
||||||
|
import { analytics } from "@/lib/analytics"
|
||||||
|
import { COMPANY_BRAIN_CAL_HREF, type SetupCallSurface } from "@/lib/cal"
|
||||||
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
|
||||||
|
export function SetupCallButton({
|
||||||
|
className,
|
||||||
|
surface,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
surface: SetupCallSurface
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={COMPANY_BRAIN_CAL_HREF}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={() => analytics.brainSetupCallClicked({ surface })}
|
||||||
|
className={cn(
|
||||||
|
dmSans125ClassName(),
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-transparent px-4 py-2.5 text-[13px] font-medium text-[#E4E4E7] transition-colors hover:bg-white/[0.06] hover:text-[#FAFAFA]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children ?? "Set up Company Brain with us"}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,6 @@ export interface AboutValues {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: BrainMode
|
mode: BrainMode
|
||||||
onModeChange: (m: BrainMode) => void
|
|
||||||
domain: string | null
|
domain: string | null
|
||||||
suggestedWorkspaceName: string
|
suggestedWorkspaceName: string
|
||||||
defaultName: string
|
defaultName: string
|
||||||
|
|
@ -60,7 +59,6 @@ export const inputClass =
|
||||||
|
|
||||||
export function StepAbout({
|
export function StepAbout({
|
||||||
mode,
|
mode,
|
||||||
onModeChange,
|
|
||||||
domain,
|
domain,
|
||||||
suggestedWorkspaceName,
|
suggestedWorkspaceName,
|
||||||
defaultName,
|
defaultName,
|
||||||
|
|
@ -165,9 +163,7 @@ export function StepAbout({
|
||||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
|
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
|
||||||
style={cardSurfaceStyle}
|
style={cardSurfaceStyle}
|
||||||
>
|
>
|
||||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
<div className="flex items-center gap-4">
|
||||||
|
|
||||||
<div className="mt-7 flex items-center gap-4">
|
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
url={avatarUrl}
|
url={avatarUrl}
|
||||||
name={values.name || defaultName}
|
name={values.name || defaultName}
|
||||||
|
|
@ -407,53 +403,6 @@ export function DomainLogo({ domain }: { domain: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModeToggle({
|
|
||||||
mode,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
mode: BrainMode
|
|
||||||
onChange: (m: BrainMode) => void
|
|
||||||
}) {
|
|
||||||
const items: { id: BrainMode; label: string }[] = [
|
|
||||||
{ id: "personal", label: "Personal" },
|
|
||||||
{ id: "team", label: "Team" },
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
|
|
||||||
style={{
|
|
||||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
|
|
||||||
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
|
|
||||||
animate={{ x: mode === "team" ? "100%" : "0%" }}
|
|
||||||
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
|
|
||||||
/>
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = mode === item.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
|
|
||||||
isActive
|
|
||||||
? "text-[#fafafa]"
|
|
||||||
: "text-[#737373] hover:text-[#fafafa]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserAvatar({
|
export function UserAvatar({
|
||||||
url,
|
url,
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,11 @@ import {
|
||||||
} from "@lib/constants"
|
} from "@lib/constants"
|
||||||
import { useCustomer } from "autumn-js/react"
|
import { useCustomer } from "autumn-js/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { connectorPause } from "@/lib/connector-availability"
|
||||||
|
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||||
import { analytics } from "@/lib/analytics"
|
import { analytics } from "@/lib/analytics"
|
||||||
import type { BrainMode } from "./types"
|
import type { BrainMode } from "./types"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
type SourceId =
|
type SourceId =
|
||||||
| "drive"
|
| "drive"
|
||||||
|
|
@ -149,11 +152,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
||||||
credits: "$20",
|
credits: "$20",
|
||||||
productId: "api_pro",
|
productId: "api_pro",
|
||||||
description: "For people building with AI memory",
|
description: "For people building with AI memory",
|
||||||
features: [
|
features: ["Auto top-up when balance runs low", "Priority support"],
|
||||||
"Auto top-up when balance runs low",
|
|
||||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
|
||||||
"Priority support",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "max",
|
id: "max",
|
||||||
|
|
@ -377,6 +376,8 @@ export function StepSources({
|
||||||
return !connectorAccess
|
return !connectorAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notify = useConnectorNotify()
|
||||||
|
|
||||||
const setState = (id: SourceId, state: SourceState) => {
|
const setState = (id: SourceId, state: SourceState) => {
|
||||||
onChange({ ...values, connected: { ...values.connected, [id]: state } })
|
onChange({ ...values, connected: { ...values.connected, [id]: state } })
|
||||||
}
|
}
|
||||||
|
|
@ -386,6 +387,11 @@ export function StepSources({
|
||||||
id: SourceId,
|
id: SourceId,
|
||||||
) => {
|
) => {
|
||||||
analytics.onboardingIntegrationClicked({ integration: provider })
|
analytics.onboardingIntegrationClicked({ integration: provider })
|
||||||
|
if (connectorPause(provider)) {
|
||||||
|
notify.request(provider)
|
||||||
|
setState(id, "waitlist")
|
||||||
|
return
|
||||||
|
}
|
||||||
setState(id, "connecting")
|
setState(id, "connecting")
|
||||||
try {
|
try {
|
||||||
const metadata: Record<string, string> = {}
|
const metadata: Record<string, string> = {}
|
||||||
|
|
@ -425,12 +431,18 @@ export function StepSources({
|
||||||
setState(id, "waitlist")
|
setState(id, "waitlist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paused beats locked: upgrading cannot unlock a connector nobody can connect.
|
||||||
const guard = (
|
const guard = (
|
||||||
plan: RequiredPlan | undefined,
|
plan: RequiredPlan | undefined,
|
||||||
title: string,
|
title: string,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
|
provider?: string,
|
||||||
) => {
|
) => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (provider && connectorPause(provider)) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isLocked(plan) && plan) {
|
if (isLocked(plan) && plan) {
|
||||||
setRequestedPlan(plan)
|
setRequestedPlan(plan)
|
||||||
setRequestedConnector(title)
|
setRequestedConnector(title)
|
||||||
|
|
@ -618,6 +630,7 @@ function OnboardingPlansModal({
|
||||||
requestedPlan: RequiredPlan
|
requestedPlan: RequiredPlan
|
||||||
}) {
|
}) {
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const { currentPlan, isLoading } = useTokenUsage(autumn)
|
const { currentPlan, isLoading } = useTokenUsage(autumn)
|
||||||
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
|
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
|
||||||
null,
|
null,
|
||||||
|
|
@ -636,8 +649,10 @@ function OnboardingPlansModal({
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId,
|
planId,
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: window.location.href,
|
successUrl: window.location.href,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||||
return
|
return
|
||||||
|
|
@ -948,19 +963,22 @@ function GoogleDriveSourceCard({
|
||||||
plan: RequiredPlan | undefined,
|
plan: RequiredPlan | undefined,
|
||||||
title: string,
|
title: string,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
|
provider?: string,
|
||||||
) => () => void
|
) => () => void
|
||||||
connectRealProvider: (
|
connectRealProvider: (
|
||||||
provider: "google-drive" | "notion" | "onedrive",
|
provider: "google-drive" | "notion" | "onedrive",
|
||||||
id: SourceId,
|
id: SourceId,
|
||||||
) => void
|
) => void
|
||||||
}) {
|
}) {
|
||||||
|
const pause = connectorPause("google-drive")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SourceCard
|
<SourceCard
|
||||||
title="Google Drive"
|
title="Google Drive"
|
||||||
blurb="Docs, sheets, slides — the working memory of your team."
|
blurb="Docs, sheets, slides — the working memory of your team."
|
||||||
icon={<GoogleDrive className="size-7" />}
|
icon={<GoogleDrive className="size-7" />}
|
||||||
state={values.connected.drive ?? "idle"}
|
state={values.connected.drive ?? "idle"}
|
||||||
ctaLabel="Connect"
|
ctaLabel={pause ? "Notify me" : "Connect"}
|
||||||
locked={isLocked("pro")}
|
locked={isLocked("pro")}
|
||||||
requiredPlan="pro"
|
requiredPlan="pro"
|
||||||
perks={[
|
perks={[
|
||||||
|
|
@ -968,11 +986,19 @@ function GoogleDriveSourceCard({
|
||||||
"Stays in sync as files change",
|
"Stays in sync as files change",
|
||||||
"You pick what to share at sign-in",
|
"You pick what to share at sign-in",
|
||||||
]}
|
]}
|
||||||
onConnect={guard("pro", "Google Drive", () =>
|
onConnect={guard(
|
||||||
connectRealProvider("google-drive", "drive"),
|
"pro",
|
||||||
|
"Google Drive",
|
||||||
|
() => connectRealProvider("google-drive", "drive"),
|
||||||
|
"google-drive",
|
||||||
)}
|
)}
|
||||||
headerNote={
|
headerNote={
|
||||||
values.driveScope === "full" ? (
|
pause ? (
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#F5A524] font-medium">
|
||||||
|
<AlertTriangle className="size-3 shrink-0" />
|
||||||
|
{pause.message}
|
||||||
|
</p>
|
||||||
|
) : values.driveScope === "full" ? (
|
||||||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
|
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
|
||||||
<AlertTriangle className="size-3 shrink-0" />
|
<AlertTriangle className="size-3 shrink-0" />
|
||||||
Full Drive can exhaust your monthly usage.
|
Full Drive can exhaust your monthly usage.
|
||||||
|
|
@ -1006,6 +1032,7 @@ function NotionSourceCard({
|
||||||
plan: RequiredPlan | undefined,
|
plan: RequiredPlan | undefined,
|
||||||
title: string,
|
title: string,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
|
provider?: string,
|
||||||
) => () => void
|
) => () => void
|
||||||
connectRealProvider: (
|
connectRealProvider: (
|
||||||
provider: "google-drive" | "notion" | "onedrive",
|
provider: "google-drive" | "notion" | "onedrive",
|
||||||
|
|
@ -1048,6 +1075,7 @@ function GranolaSourceCard({
|
||||||
plan: RequiredPlan | undefined,
|
plan: RequiredPlan | undefined,
|
||||||
title: string,
|
title: string,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
|
provider?: string,
|
||||||
) => () => void
|
) => () => void
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1092,6 +1120,7 @@ function MoreSourcesGrid({
|
||||||
plan: RequiredPlan | undefined,
|
plan: RequiredPlan | undefined,
|
||||||
title: string,
|
title: string,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
|
provider?: string,
|
||||||
) => () => void
|
) => () => void
|
||||||
openExternal: (id: SourceId, url: string) => void
|
openExternal: (id: SourceId, url: string) => void
|
||||||
requestWaitlist: (id: SourceId) => void
|
requestWaitlist: (id: SourceId) => void
|
||||||
|
|
@ -1283,7 +1312,9 @@ function SourceCard({
|
||||||
{isDone ? (
|
{isDone ? (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
||||||
<Check className="size-3.5" />
|
<Check className="size-3.5" />
|
||||||
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
|
{state === "waitlist"
|
||||||
|
? "We'll email you"
|
||||||
|
: (doneLabel ?? "Connected")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,12 @@
|
||||||
|
|
||||||
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
|
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
|
||||||
import { GradientLogo } from "@ui/assets/Logo"
|
import { GradientLogo } from "@ui/assets/Logo"
|
||||||
import { Button } from "@ui/components/button"
|
|
||||||
import { cn } from "@lib/utils"
|
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 { SlackMark } from "@/components/brain-connector-icons"
|
||||||
import { analytics } from "@/lib/analytics"
|
|
||||||
import { dmSans125ClassName } from "@/lib/fonts"
|
import { dmSans125ClassName } from "@/lib/fonts"
|
||||||
|
|
||||||
const BACKEND =
|
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
||||||
|
|
||||||
export const CHECKOUT_RETURN_PARAM = "brainTrial"
|
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 = [
|
const ORBIT = [
|
||||||
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
|
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
|
||||||
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
|
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
|
||||||
|
|
@ -85,91 +61,11 @@ function BrainPanel() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineRow({
|
// Trial checkout is disabled while new Company Brain signups are paused.
|
||||||
date,
|
export function StepTrial(_props: { onActive: () => void }) {
|
||||||
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 (
|
return (
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<h2
|
<h2
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -177,58 +73,11 @@ export function StepTrial({ onActive }: { onActive: () => void }) {
|
||||||
"text-[22px] leading-tight font-medium text-[#fafafa]",
|
"text-[22px] leading-tight font-medium text-[#fafafa]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Start your {TRIAL_DAYS}-day free trial
|
New signups are paused
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
|
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
|
||||||
Add a payment method to start. You will not be charged today. We
|
Company Brain isn't accepting new workspaces right now. If you have
|
||||||
will email you before your first payment.
|
questions, reach us at support@supermemory.com.
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -394,11 +394,6 @@ export function SelectSpacesModal({
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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 {
|
const errorData = (await res.json().catch(() => ({}))) as {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||||
|
|
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
||||||
features: [
|
features: [
|
||||||
"Pay-as-you-go after $5 runs out",
|
"Pay-as-you-go after $5 runs out",
|
||||||
"Full search and memory access",
|
"Full search and memory access",
|
||||||
|
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||||
"Email support",
|
"Email support",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
||||||
features: [
|
features: [
|
||||||
"Auto top-up when balance runs low",
|
"Auto top-up when balance runs low",
|
||||||
"Google Drive, Notion, OneDrive & Granola connectors",
|
"Google Drive, Notion, OneDrive & Granola connectors",
|
||||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
|
||||||
"Priority support",
|
"Priority support",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -531,6 +532,7 @@ export default function Billing() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { user, org } = useAuth()
|
const { user, org } = useAuth()
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
const isCompanyBrain = useHasCompanyBrain()
|
const isCompanyBrain = useHasCompanyBrain()
|
||||||
const brainTrial = useMemo(
|
const brainTrial = useMemo(
|
||||||
|
|
@ -698,8 +700,10 @@ export default function Billing() {
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId,
|
planId,
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/settings#billing`,
|
successUrl: `${window.location.origin}/settings#billing`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||||
return
|
return
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -90,13 +90,6 @@ const ROWS: {
|
||||||
effortHelp:
|
effortHelp:
|
||||||
"Deeper thinking routes messages more carefully but takes longer.",
|
"Deeper thinking routes messages more carefully but takes longer.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
role: "research",
|
|
||||||
effortKey: "researchEffort",
|
|
||||||
title: "Web research",
|
|
||||||
help: "Looks things up on the web when researching your company.",
|
|
||||||
effortHelp: "Deeper research per web search, at the cost of speed.",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
type FullConfig = Required<BrainModelConfig>
|
type FullConfig = Required<BrainModelConfig>
|
||||||
|
|
@ -132,10 +125,8 @@ const PRESETS: PresetDef[] = [
|
||||||
? "grok-4-fast"
|
? "grok-4-fast"
|
||||||
: defaults.main,
|
: defaults.main,
|
||||||
triage: defaults.triage,
|
triage: defaults.triage,
|
||||||
research: defaults.research,
|
|
||||||
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
|
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
|
||||||
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
|
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
|
||||||
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -145,35 +136,24 @@ const PRESETS: PresetDef[] = [
|
||||||
build: (defaults) => ({
|
build: (defaults) => ({
|
||||||
main: defaults.main,
|
main: defaults.main,
|
||||||
triage: defaults.triage,
|
triage: defaults.triage,
|
||||||
research: defaults.research,
|
|
||||||
mainEffort: defaults.mainEffort ?? "high",
|
mainEffort: defaults.mainEffort ?? "high",
|
||||||
triageEffort: defaults.triageEffort ?? "low",
|
triageEffort: defaults.triageEffort ?? "low",
|
||||||
researchEffort: defaults.researchEffort ?? "high",
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "thorough",
|
id: "thorough",
|
||||||
label: "Most thorough",
|
label: "Most thorough",
|
||||||
description: "Deepest answers and research. Slower, uses more credits.",
|
description: "Deepest answers. Slower, uses more credits.",
|
||||||
build: (defaults, choices) => ({
|
build: (defaults, choices) => ({
|
||||||
main: defaults.main,
|
main: defaults.main,
|
||||||
triage: defaults.triage,
|
triage: defaults.triage,
|
||||||
research: defaults.research,
|
|
||||||
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
|
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
|
||||||
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
|
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
|
||||||
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const CONFIG_KEYS = [
|
const CONFIG_KEYS = ["main", "triage", "mainEffort", "triageEffort"] as const
|
||||||
"main",
|
|
||||||
"triage",
|
|
||||||
"research",
|
|
||||||
"mainEffort",
|
|
||||||
"triageEffort",
|
|
||||||
"researchEffort",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const extraHighIsBounded = (model: string): boolean =>
|
const extraHighIsBounded = (model: string): boolean =>
|
||||||
model.startsWith("grok-") || model.startsWith("gpt-")
|
model.startsWith("grok-") || model.startsWith("gpt-")
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
getConnectionSubtitle,
|
getConnectionSubtitle,
|
||||||
} from "@/components/settings/sync-utils"
|
} from "@/components/settings/sync-utils"
|
||||||
import type { ImportProvider } 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>
|
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||||
|
|
||||||
|
|
@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) {
|
||||||
export default function ConnectionsMCP() {
|
export default function ConnectionsMCP() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const autumn = useCustomer()
|
const autumn = useCustomer()
|
||||||
|
const promoCode = usePromoCode()
|
||||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [removeDialog, setRemoveDialog] = useState<{
|
const [removeDialog, setRemoveDialog] = useState<{
|
||||||
|
|
@ -552,8 +554,10 @@ export default function ConnectionsMCP() {
|
||||||
try {
|
try {
|
||||||
const result = await autumn.attach({
|
const result = await autumn.attach({
|
||||||
planId: "api_pro",
|
planId: "api_pro",
|
||||||
|
discounts: promoCode.getDiscounts(),
|
||||||
successUrl: `${window.location.origin}/settings#connections`,
|
successUrl: `${window.location.origin}/settings#connections`,
|
||||||
})
|
})
|
||||||
|
promoCode.clear()
|
||||||
if (result?.paymentUrl) {
|
if (result?.paymentUrl) {
|
||||||
window.open(result.paymentUrl, "_self")
|
window.open(result.paymentUrl, "_self")
|
||||||
return
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
apps/web/components/setup-call-link.tsx
Normal file
45
apps/web/components/setup-call-link.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CalendarClock } from "lucide-react"
|
||||||
|
import { Button } from "@ui/components/button"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||||
|
import { cn } from "@lib/utils"
|
||||||
|
import { analytics } from "@/lib/analytics"
|
||||||
|
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
|
||||||
|
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||||
|
import { dmSansClassName } from "@/lib/fonts"
|
||||||
|
|
||||||
|
export function SetupCallLink() {
|
||||||
|
const { data } = useTrialStatus()
|
||||||
|
if (!data?.active) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="headers"
|
||||||
|
className={cn(
|
||||||
|
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
|
||||||
|
dmSansClassName(),
|
||||||
|
)}
|
||||||
|
aria-label="Book a setup call"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={COMPANY_BRAIN_CAL_HREF}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={() =>
|
||||||
|
analytics.brainSetupCallClicked({ surface: "header" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarClock className="size-4 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||||
|
Book a setup call
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
|
||||||
|
|
||||||
const BACKEND =
|
|
||||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
||||||
|
|
||||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
|
||||||
|
|
||||||
function SlackMark({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
|
||||||
<title>Slack</title>
|
|
||||||
<path
|
|
||||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
|
||||||
fill="#E01E5A"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
|
||||||
fill="#E01E5A"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
|
||||||
fill="#36C5F0"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
|
||||||
fill="#36C5F0"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
|
||||||
fill="#2EB67D"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
|
||||||
fill="#2EB67D"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
|
||||||
fill="#ECB22E"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
|
||||||
fill="#ECB22E"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SlackConnectCard() {
|
|
||||||
const isCompanyBrain = useHasCompanyBrain()
|
|
||||||
const [status, setStatus] = useState<SlackStatus | null>(null)
|
|
||||||
const [trialActive, setTrialActive] = useState(true)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCompanyBrain) return
|
|
||||||
let active = true
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [isCompanyBrain])
|
|
||||||
|
|
||||||
if (!isCompanyBrain || loading) return null
|
|
||||||
|
|
||||||
const connected = status?.connected
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:px-5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold text-fg-primary">
|
|
||||||
Add Supermemory to your Slack
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
|
|
||||||
{connected
|
|
||||||
? `Connected to ${status?.teamName ?? "your workspace"}.`
|
|
||||||
: "Answer from your company brain and act on connected apps — right inside Slack."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{connected ? (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-surface-skeleton px-3 py-1.5 text-[12px] font-medium text-fg-muted ring-1 ring-surface-border">
|
|
||||||
<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]"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue