mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-07 02:58:11 +00:00
Merge main into feat/zoom-connector
This commit is contained in:
commit
0e06b2b5f5
301 changed files with 44840 additions and 7532 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint
|
|||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
name: Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
9
.github/workflows/claude-auto-fix-ci.yml
vendored
9
.github/workflows/claude-auto-fix-ci.yml
vendored
|
|
@ -19,6 +19,7 @@ jobs:
|
|||
github.event.workflow_run.conclusion == 'failure' &&
|
||||
github.event.workflow_run.pull_requests[0]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
|
@ -41,18 +42,22 @@ jobs:
|
|||
- name: Get CI failure details
|
||||
id: failure_details
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
with:
|
||||
script: |
|
||||
const runId = Number(process.env.RUN_ID);
|
||||
|
||||
const run = await github.rest.actions.getWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure');
|
||||
|
|
|
|||
9
.github/workflows/claude-code-review.yml
vendored
9
.github/workflows/claude-code-review.yml
vendored
|
|
@ -4,14 +4,23 @@ on:
|
|||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Fork PRs run with a read-only token and no secrets/OIDC id-token, so the
|
||||
# Claude action can never authenticate there and the job always fails. Skip
|
||||
# it for forks so those PRs report a clean skipped check instead of a red X.
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'graphite-app[bot]' &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
|
|
|||
33
.github/workflows/claude.yml
vendored
33
.github/workflows/claude.yml
vendored
|
|
@ -13,11 +13,36 @@ on:
|
|||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/agent-framework-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-ai-sdk.yml
vendored
5
.github/workflows/publish-ai-sdk.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/ai-sdk/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/cartesia-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-memory-graph.yml
vendored
5
.github/workflows/publish-memory-graph.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/memory-graph/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/openai-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/pipecat-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-tools.yml
vendored
5
.github/workflows/publish-tools.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/tools/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed:
|
|||
# You'll need to add your API keys and database URLs
|
||||
```
|
||||
|
||||
4. **Change proxy for local development**
|
||||
|
||||
Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`):
|
||||
|
||||
```ts
|
||||
if (url.hostname === "localhost") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
5. **Start the Development Server**
|
||||
4. **Start the Development Server**
|
||||
|
||||
```bash
|
||||
bun run dev:local
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -7,12 +7,13 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>State-of-the-art memory and context engine for AI.</strong>
|
||||
<strong>State-of-the-art memory and context engine for AI. And yes - you can use it as a company/personal brain.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://supermemory.ai/docs">Docs</a> ·
|
||||
<a href="https://supermemory.ai/docs/quickstart">Quickstart</a> ·
|
||||
<a href="https://supermemory.ai/docs/self-hosting/overview">Self-host</a> ·
|
||||
<a href="https://console.supermemory.ai">Dashboard</a> ·
|
||||
<a href="https://supermemory.link/discord">Discord</a>
|
||||
</p>
|
||||
|
|
@ -23,6 +24,10 @@
|
|||
<a href="https://supermemory.ai/docs"><img src="https://img.shields.io/badge/docs-supermemory.ai-blue?style=flat-square" alt="docs" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory.
|
||||
|
|
@ -73,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies.
|
|||
|
||||
**[→ Jump to developer quickstart](#build-with-supermemory-api)**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
<h3>🖥️ I want to run it myself</h3>
|
||||
|
||||
State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
|
||||
**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -297,6 +317,39 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs)
|
|||
|
||||
---
|
||||
|
||||
## Supermemory local — run it yourself
|
||||
|
||||
State-of-the-art memory, on your machine. One binary. Zero config.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
# or
|
||||
npx supermemory local
|
||||
```
|
||||
|
||||
```bash
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`.
|
||||
|
||||
```typescript
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...",
|
||||
baseURL: "http://localhost:6767", // that's the only change
|
||||
});
|
||||
```
|
||||
|
||||
- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
|
||||
- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud.
|
||||
- **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine.
|
||||
- **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move.
|
||||
- **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`.
|
||||
|
||||
Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Supermemory is state of the art across all major AI memory benchmarks:
|
||||
|
|
@ -350,6 +403,7 @@ Your app / AI tool
|
|||
|
||||
- 📖 [Documentation](https://supermemory.ai/docs)
|
||||
- 🚀 [Quickstart](https://supermemory.ai/docs/quickstart)
|
||||
- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview)
|
||||
- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview)
|
||||
- 🔌 [Integrations](https://supermemory.ai/docs/integrations)
|
||||
- 💬 [Discord](https://supermemory.link/discord)
|
||||
|
|
|
|||
364
README.zh-CN.md
Normal file
364
README.zh-CN.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
<p align="center">
|
||||
<picture>
|
||||
<source srcset="apps/web/public/logo-fullmark.svg" media="(prefers-color-scheme: dark)">
|
||||
<source srcset="apps/web/public/logo-light-fullmark.svg" media="(prefers-color-scheme: light)">
|
||||
<img src="apps/web/public/logo-fullmark.svg" alt="Supermemory" width="400" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>面向 AI 的记忆与上下文引擎,业界领先。也可以把它当作公司或个人的「大脑」来用。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://supermemory.ai/docs">文档</a> ·
|
||||
<a href="https://supermemory.ai/docs/quickstart">快速开始</a> ·
|
||||
<a href="https://console.supermemory.ai">控制台</a> ·
|
||||
<a href="https://supermemory.link/discord">Discord</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/supermemory"><img src="https://img.shields.io/npm/v/supermemory?style=flat-square&color=blue" alt="npm" /></a>
|
||||
<a href="https://pypi.org/project/supermemory/"><img src="https://img.shields.io/pypi/v/supermemory?style=flat-square&color=blue" alt="pypi" /></a>
|
||||
<a href="https://supermemory.ai/docs"><img src="https://img.shields.io/badge/docs-supermemory.ai-blue?style=flat-square" alt="docs" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · <strong>简体中文</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Supermemory 是为 AI 设计的记忆与上下文层。在 **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)、[LoCoMo](https://github.com/snap-research/locomo) 和 [ConvoMem](https://github.com/Salesforce/ConvoMem)** 这三大 AI 记忆基准上均位列第一。
|
||||
|
||||
我们是一个研究团队,围绕这个引擎打造插件和工具生态。
|
||||
|
||||
你的 AI 在对话之间会把一切忘干净,Supermemory 就是来解决这个问题的。
|
||||
|
||||
它会自动从对话中学习,提取事实、构建用户画像、处理知识更新与冲突、淘汰过期信息,并在合适的时机交付合适的上下文。完整 RAG、连接器、文件处理——整个上下文栈,一套系统全部搞定。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 🧠 **记忆** | 从对话中提取事实,处理时间变化、冲突,以及自动遗忘。 |
|
||||
| 👤 **用户画像** | 自动维护的用户上下文——稳定事实 + 近期动态。一次调用,约 50ms。 |
|
||||
| 🔍 **混合检索** | 一次查询同时跑 RAG 和记忆。知识库文档与个性化上下文一起返回。 |
|
||||
| 🔌 **连接器** | Google Drive · Gmail · Notion · OneDrive · GitHub——实时 webhook 自动同步。 |
|
||||
| 📄 **多模态抽取** | PDF、图片(OCR)、视频(转写)、代码(按 AST 切分)。上传即用。 |
|
||||
|
||||
这一切都收敛在我们统一的记忆结构和本体里。
|
||||
|
||||
<img width="1414" height="937" alt="image" src="https://github.com/user-attachments/assets/8863b6d9-c043-4c75-b200-4f1759e7edaf" />
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 怎么用 Supermemory
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
<h3>🧑💻 我只是 AI 工具的用户</h3>
|
||||
|
||||
直接用我们的应用,给自己搭一份专属的 supermemory。它会**在每次对话之间维护一张持久的记忆图谱**。
|
||||
|
||||
你的 AI 会记住你的偏好、项目、历史讨论——而且越用越聪明。
|
||||
|
||||
**[→ 跳到用户安装指南](#给你的-ai-装上记忆)**
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
<h3>🔧 我在做 AI 产品</h3>
|
||||
|
||||
通过**一套 API**,就能给你的 agent 和应用加上记忆、RAG、用户画像和连接器。
|
||||
|
||||
无需配置向量数据库,无需搭 embedding 流水线,无需操心切分策略。
|
||||
|
||||
**[→ 跳到开发者快速开始](#用-supermemory-api-构建)**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 给你的 AI 装上记忆
|
||||
|
||||
Supermemory 的应用、浏览器扩展、插件和 MCP 服务器,可以为任何兼容的 AI 助手提供持久记忆。装一次,AI 从此记住你。
|
||||
|
||||
### 应用
|
||||
|
||||
不用写代码,直接用我们面向消费者的应用——免费。
|
||||
|
||||
入口:https://app.supermemory.ai
|
||||
|
||||
<img width="1705" height="1030" alt="image" src="https://github.com/user-attachments/assets/5b43af30-b998-4585-8de6-f3e9a26d894a" />
|
||||
|
||||
应用里内置了一个 agent,我们叫它 Nova。
|
||||
|
||||
### Supermemory 插件
|
||||
|
||||
Supermemory 已经为 Claude Code、OpenCode、OpenClaw、Hermes 提供了开箱即用的插件。
|
||||
|
||||
<img width="844" height="484" alt="image" src="https://github.com/user-attachments/assets/ecb879a2-8652-495d-9228-f305a97ba603" />
|
||||
|
||||
这些插件本质上是 supermemory API 的实现,全部开源:
|
||||
|
||||
- Openclaw 插件:https://github.com/supermemoryai/openclaw-supermemory
|
||||
- Claude Code 插件:https://github.com/supermemoryai/claude-supermemory
|
||||
- OpenCode 插件:https://github.com/supermemoryai/opencode-supermemory
|
||||
- Hermes agent(Supermemory 作为记忆 provider):https://github.com/NousResearch/hermes-agent
|
||||
|
||||
### MCP——一键安装
|
||||
|
||||
```bash
|
||||
npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client claude --oauth=yes
|
||||
```
|
||||
|
||||
把 `claude` 换成你用的客户端即可:`cursor`、`windsurf`、`vscode` 等等。
|
||||
|
||||
更多 MCP 细节见:https://supermemory.ai/docs/supermemory-mcp/mcp
|
||||
|
||||
### AI 接入后能用到什么
|
||||
|
||||
| 工具 | 作用 |
|
||||
|---|---|
|
||||
| `memory` | 保存或删除信息。每当你说出值得记住的内容,AI 会自动调用。 |
|
||||
| `recall` | 按查询条件检索记忆。返回相关记忆 + 用户画像摘要。 |
|
||||
| `context` | 在对话开始时把你完整的画像(偏好、近期活动)注入会话。Cursor 和 Claude Code 里输入 `/context` 即可。 |
|
||||
|
||||
### 工作机制
|
||||
|
||||
装好之后,Supermemory 在后台运行:
|
||||
|
||||
1. **正常和你的 AI 对话。** 聊聊偏好、提提项目、讨论一下问题。
|
||||
2. **Supermemory 自动提取并存下关键信息。** 事实、偏好、项目上下文——杂音不留。
|
||||
3. **下一次对话,AI 已经认识你了。** 它知道你在做什么、你喜欢怎么处理、你之前聊过什么。
|
||||
|
||||
记忆按 **project(容器标签)** 隔离,便于把工作和生活分开,也可以按客户、仓库或任何其它维度组织。
|
||||
|
||||
### 已支持的客户端
|
||||
|
||||
**Claude Desktop** · **Cursor** · **Windsurf** · **VS Code** · **Claude Code** · **OpenCode** · **OpenClaw** · **Hermes**
|
||||
|
||||
MCP 服务器开源——[查看源码](https://supermemory.ai/docs/supermemory-mcp/mcp)。
|
||||
|
||||
### 手动配置
|
||||
|
||||
把下面这段加到你的 MCP 客户端配置里:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supermemory": {
|
||||
"url": "https://mcp.supermemory.ai/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果想用 API key 代替 OAuth:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supermemory": {
|
||||
"url": "https://mcp.supermemory.ai/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer sm_your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用 Supermemory API 构建
|
||||
|
||||
如果你在做 AI agent 或应用,Supermemory 用一套 API 给你完整的上下文栈——记忆、RAG、用户画像、连接器、文件处理,全包。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
npm install supermemory # 或者:pip install supermemory
|
||||
```
|
||||
|
||||
### 快速开始
|
||||
|
||||
```typescript
|
||||
import Supermemory from "supermemory";
|
||||
|
||||
const client = new Supermemory();
|
||||
|
||||
// 存一段对话
|
||||
await client.add({
|
||||
content: "User loves TypeScript and prefers functional patterns",
|
||||
containerTag: "user_123",
|
||||
});
|
||||
|
||||
// 一次调用拿到用户画像 + 相关记忆
|
||||
const { profile, searchResults } = await client.profile({
|
||||
containerTag: "user_123",
|
||||
q: "What programming style does the user prefer?",
|
||||
});
|
||||
|
||||
// profile.static → ["Loves TypeScript", "Prefers functional patterns"]
|
||||
// profile.dynamic → ["Working on API integration"]
|
||||
// searchResults → 按相似度排序的相关记忆
|
||||
```
|
||||
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory()
|
||||
|
||||
client.add(
|
||||
content="User loves TypeScript and prefers functional patterns",
|
||||
container_tag="user_123"
|
||||
)
|
||||
|
||||
result = client.profile(container_tag="user_123", q="programming style")
|
||||
|
||||
print(result.profile.static) # 长期事实
|
||||
print(result.profile.dynamic) # 近期上下文
|
||||
```
|
||||
|
||||
Supermemory 会自动抽取记忆、构建用户画像、返回相关上下文。不用搭 embedding 流水线,不用配向量库,不用操心切分。
|
||||
|
||||
### 框架集成
|
||||
|
||||
主流 AI 框架都有开箱即用的封装:
|
||||
|
||||
```typescript
|
||||
// Vercel AI SDK
|
||||
import { withSupermemory } from "@supermemory/tools/ai-sdk";
|
||||
const model = withSupermemory(openai("gpt-4o"), { containerTag: "user_123", customId: "conv-1" });
|
||||
|
||||
// Mastra
|
||||
import { withSupermemory } from "@supermemory/tools/mastra";
|
||||
const agent = new Agent(withSupermemory(config, "user-123", { mode: "full" }));
|
||||
```
|
||||
|
||||
**Vercel AI SDK** · **LangChain** · **LangGraph** · **OpenAI Agents SDK** · **Mastra** · **Agno** · **Claude Memory Tool** · **n8n**
|
||||
|
||||
### 检索模式
|
||||
|
||||
```typescript
|
||||
// 混合检索(默认)——一次查询同时跑 RAG 和记忆
|
||||
const results = await client.search.memories({
|
||||
q: "how do I deploy?",
|
||||
containerTag: "user_123",
|
||||
searchMode: "hybrid",
|
||||
});
|
||||
// 返回部署文档(RAG)+ 该用户的部署偏好(记忆)
|
||||
|
||||
// 只查记忆
|
||||
const results = await client.search.memories({
|
||||
q: "user preferences",
|
||||
containerTag: "user_123",
|
||||
searchMode: "memories",
|
||||
});
|
||||
```
|
||||
|
||||
### 用户画像
|
||||
|
||||
传统记忆方案依赖检索——你得先知道要问什么。Supermemory 会自动为每个用户维护一份画像:
|
||||
|
||||
```typescript
|
||||
const { profile } = await client.profile({ containerTag: "user_123" });
|
||||
|
||||
// profile.static → ["Senior engineer at Acme", "Prefers dark mode", "Uses Vim"]
|
||||
// profile.dynamic → ["Working on auth migration", "Debugging rate limits"]
|
||||
```
|
||||
|
||||
一次调用,约 50ms。把它塞到 system prompt 里,你的 agent 立刻就知道自己在和谁聊。
|
||||
|
||||
### 连接器
|
||||
|
||||
把外部数据自动同步进知识库:
|
||||
|
||||
**Google Drive** · **Gmail** · **Notion** · **OneDrive** · **GitHub** · **网页爬取**
|
||||
|
||||
实时 webhook。文档自动处理、切分、入索引。
|
||||
|
||||
### API 一览
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `client.add()` | 存储内容——文本、对话、URL、HTML |
|
||||
| `client.profile()` | 一次调用返回用户画像 + 可选检索 |
|
||||
| `client.search.memories()` | 跨记忆和文档的混合检索 |
|
||||
| `client.search.documents()` | 带元数据过滤的文档检索 |
|
||||
| `client.documents.uploadFile()` | 上传 PDF、图片、视频、代码 |
|
||||
| `client.documents.list()` | 列出和筛选文档 |
|
||||
| `client.settings.update()` | 配置记忆抽取与切分策略 |
|
||||
|
||||
完整 API 文档 → [supermemory.ai/docs](https://supermemory.ai/docs)
|
||||
|
||||
---
|
||||
|
||||
## 基准测试
|
||||
|
||||
在各大 AI 记忆基准上,Supermemory 都处于业界顶尖水平:
|
||||
|
||||
| 基准 | 衡量内容 | 结果 |
|
||||
|---|---|---|
|
||||
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | 跨会话的长期记忆能力,包含知识更新 | **81.6%——第 1** |
|
||||
| **[LoCoMo](https://github.com/snap-research/locomo)** | 超长对话中的事实回忆(单跳、多跳、时序、对抗) | **第 1** |
|
||||
| **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | 个性化与偏好学习 | **第 1** |
|
||||
|
||||
我们还做了 **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)**——一个开源框架,用于对记忆服务做标准化、可复现的基准评测。Supermemory、Mem0、Zep 等可以直接放在一起对比:
|
||||
|
||||
```bash
|
||||
bun run src/index.ts run -p supermemory -b longmemeval -j gpt-4o -r my-run
|
||||
```
|
||||
|
||||
### 给自己的记忆方案跑基准
|
||||
|
||||
我们提供了一个 Agent skill,方便公司把自己的上下文/记忆方案拿来和 supermemory 对比。
|
||||
|
||||
```
|
||||
npx skills add supermemoryai/memorybench
|
||||
```
|
||||
|
||||
跑完上面这条命令,再执行 `/benchmark-context`,Supermemory 会自动帮你跑完整个流程。
|
||||
|
||||
---
|
||||
|
||||
## 记忆是怎么跑起来的
|
||||
|
||||
```
|
||||
你的应用 / AI 工具
|
||||
↓
|
||||
Supermemory
|
||||
│
|
||||
├── 记忆引擎 提取事实、跟踪更新、解决冲突、
|
||||
│ 自动遗忘过期信息
|
||||
├── 用户画像 基于引擎构建的静态事实 + 动态上下文,始终保持最新
|
||||
├── 混合检索 一次查询同时跑 RAG 和记忆
|
||||
├── 连接器 从 Google Drive、Gmail、Notion、GitHub 等实时同步
|
||||
└── 文件处理 PDF、图片、视频、代码 → 可检索的分块
|
||||
```
|
||||
|
||||
**记忆不等于 RAG。** RAG 是检索文档片段——无状态,对所有人返回相同结果。记忆是**长期跟踪和提取关于用户的事实**。它知道「我刚搬到 SF」会覆盖「我住在 NYC」。Supermemory 默认把两者一起跑,所以每次查询都能同时拿到知识库检索结果和个性化上下文。更多细节见:https://supermemory.ai/docs/concepts/memory-vs-rag
|
||||
|
||||
**自动遗忘。** Supermemory 知道哪些记忆已经无关紧要。临时事实(「我明天有考试」)会在日期过后失效,冲突会自动消解,杂音不会沉淀成永久记忆。
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
- 📖 [文档](https://supermemory.ai/docs)
|
||||
- 🚀 [快速开始](https://supermemory.ai/docs/quickstart)
|
||||
- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview)
|
||||
- 🔌 [集成](https://supermemory.ai/docs/integrations)
|
||||
- 💬 [Discord](https://supermemory.link/discord)
|
||||
- 𝕏 [Twitter](https://twitter.com/supermemory)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>是时候给你的 AI 一份记忆了。</strong>
|
||||
</p>
|
||||
|
|
@ -148,7 +148,17 @@ export default defineBackground(() => {
|
|||
eventSource: string,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
|
||||
try {
|
||||
const responseData = await searchMemories(data)
|
||||
let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT
|
||||
try {
|
||||
const defaultProject = await getDefaultProject()
|
||||
if (defaultProject?.containerTag) {
|
||||
containerTag = defaultProject.containerTag
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to get default project, using fallback:", error)
|
||||
}
|
||||
|
||||
const responseData = await searchMemories(data, containerTag)
|
||||
const response = responseData as {
|
||||
results?: Array<{ memory?: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function initializeClaude() {
|
|||
}
|
||||
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToClaudeMemoryDialog()
|
||||
addSupermemoryIconToClaudeInput()
|
||||
setupClaudeAutoFetch()
|
||||
}, 2000)
|
||||
|
|
@ -59,6 +60,7 @@ function setupClaudeRouteChangeDetection() {
|
|||
currentUrl = window.location.href
|
||||
console.log("Claude route changed, re-adding supermemory icon")
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToClaudeMemoryDialog()
|
||||
addSupermemoryIconToClaudeInput()
|
||||
setupClaudeAutoFetch()
|
||||
}, 1000)
|
||||
|
|
@ -79,10 +81,13 @@ function setupClaudeRouteChangeDetection() {
|
|||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element
|
||||
if (
|
||||
element.querySelector?.('[role="dialog"]') ||
|
||||
element.querySelector?.('div[contenteditable="true"]') ||
|
||||
element.querySelector?.("textarea") ||
|
||||
element.matches?.('[role="dialog"]') ||
|
||||
element.matches?.('div[contenteditable="true"]') ||
|
||||
element.matches?.("textarea")
|
||||
element.matches?.("textarea") ||
|
||||
element.textContent?.includes("Manage memory")
|
||||
) {
|
||||
shouldRecheck = true
|
||||
}
|
||||
|
|
@ -95,6 +100,7 @@ function setupClaudeRouteChangeDetection() {
|
|||
claudeObserverThrottle = setTimeout(() => {
|
||||
try {
|
||||
claudeObserverThrottle = null
|
||||
addSupermemoryButtonToClaudeMemoryDialog()
|
||||
addSupermemoryIconToClaudeInput()
|
||||
setupClaudeAutoFetch()
|
||||
} catch (error) {
|
||||
|
|
@ -264,6 +270,190 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
}
|
||||
}
|
||||
|
||||
function getClaudeMemoryDialog(): HTMLElement | null {
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
)
|
||||
|
||||
for (const dialog of dialogs) {
|
||||
const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find(
|
||||
(element) => element.textContent?.trim() === "Manage memory",
|
||||
)
|
||||
if (heading) return dialog
|
||||
}
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll<HTMLElement>("div"))
|
||||
.filter((element) => {
|
||||
const text = element.textContent || ""
|
||||
if (
|
||||
!text.includes("Manage memory") ||
|
||||
!text.includes("Here's what Claude remembers")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 400 && rect.height > 250
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect()
|
||||
const rectB = b.getBoundingClientRect()
|
||||
return rectA.width * rectA.height - rectB.width * rectB.height
|
||||
})
|
||||
|
||||
return candidates[0] || null
|
||||
}
|
||||
|
||||
function getClaudeMemoryText(dialog: HTMLElement): string {
|
||||
const clonedDialog = dialog.cloneNode(true) as HTMLElement
|
||||
clonedDialog.querySelector("#supermemory-save-button")?.remove()
|
||||
|
||||
const sanitizeClaudeMemoryText = (text: string) =>
|
||||
text
|
||||
.replace(/^Memories from Claude:\s*/i, "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(
|
||||
(line) =>
|
||||
line &&
|
||||
line !== "Tell Claude what to remember or forget..." &&
|
||||
line !== "Save to supermemory",
|
||||
)
|
||||
.join("\n")
|
||||
.trim()
|
||||
|
||||
const memorySections = Array.from(
|
||||
clonedDialog.querySelectorAll<HTMLElement>(
|
||||
"article, section, [class*='border'], [class*='rounded']",
|
||||
),
|
||||
)
|
||||
.map((element) => element.innerText || element.textContent || "")
|
||||
.map(sanitizeClaudeMemoryText)
|
||||
.filter((text) => {
|
||||
return (
|
||||
text.length > 80 &&
|
||||
!text.includes("Manage edits") &&
|
||||
!text.includes("Save to supermemory") &&
|
||||
!text.includes("Tell Claude what to remember or forget")
|
||||
)
|
||||
})
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
if (memorySections[0]) return memorySections[0]
|
||||
|
||||
return sanitizeClaudeMemoryText(
|
||||
clonedDialog.innerText || clonedDialog.textContent || "",
|
||||
)
|
||||
}
|
||||
|
||||
function addSupermemoryButtonToClaudeMemoryDialog() {
|
||||
const memoryDialog = getClaudeMemoryDialog()
|
||||
if (!memoryDialog) return
|
||||
|
||||
if (memoryDialog.querySelector("#supermemory-save-button")) return
|
||||
|
||||
const supermemoryButton = document.createElement("button")
|
||||
supermemoryButton.id = "supermemory-save-button"
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
|
||||
supermemoryButton.innerHTML = `
|
||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
|
||||
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
|
||||
<span style="white-space: nowrap;">Save to supermemory</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
supermemoryButton.style.cssText = `
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: auto !important;
|
||||
min-width: 190px !important;
|
||||
background: #1C2026 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #1C2026 !important;
|
||||
border-radius: 9999px !important;
|
||||
padding: 10px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 20px !important;
|
||||
white-space: nowrap !important;
|
||||
margin: 8px 0 8px 0 !important;
|
||||
transform: translateX(-16px) !important;
|
||||
cursor: pointer !important;
|
||||
font-family: inherit !important;
|
||||
`
|
||||
|
||||
supermemoryButton.addEventListener("mouseenter", () => {
|
||||
supermemoryButton.style.backgroundColor = "#2B2E33"
|
||||
})
|
||||
|
||||
supermemoryButton.addEventListener("mouseleave", () => {
|
||||
supermemoryButton.style.backgroundColor = "#1C2026"
|
||||
})
|
||||
|
||||
supermemoryButton.addEventListener("click", async () => {
|
||||
await saveClaudeMemoriesToSupermemory(memoryDialog)
|
||||
})
|
||||
|
||||
const introText = Array.from(
|
||||
memoryDialog.querySelectorAll<HTMLElement>("p, div"),
|
||||
).find((element) =>
|
||||
element.textContent?.includes("Here's what Claude remembers"),
|
||||
)
|
||||
|
||||
if (introText?.parentElement) {
|
||||
introText.parentElement.insertBefore(
|
||||
supermemoryButton,
|
||||
introText.nextSibling,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find(
|
||||
(element) => element.textContent?.trim() === "Manage memory",
|
||||
)
|
||||
|
||||
if (heading?.parentElement) {
|
||||
heading.parentElement.insertBefore(supermemoryButton, heading.nextSibling)
|
||||
return
|
||||
}
|
||||
|
||||
memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild)
|
||||
}
|
||||
|
||||
async function saveClaudeMemoriesToSupermemory(memoryDialog: HTMLElement) {
|
||||
try {
|
||||
DOMUtils.showToast("loading")
|
||||
|
||||
const memoryText = getClaudeMemoryText(memoryDialog)
|
||||
if (!memoryText) {
|
||||
DOMUtils.showToast("error")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
data: {
|
||||
html: memoryText,
|
||||
},
|
||||
actionSource: "claude_memories_dialog",
|
||||
})
|
||||
|
||||
console.log({ response })
|
||||
|
||||
if (response.success) {
|
||||
DOMUtils.showToast("success")
|
||||
} else {
|
||||
DOMUtils.showToast("error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving Claude memories to supermemory:", error)
|
||||
DOMUtils.showToast("error")
|
||||
}
|
||||
}
|
||||
|
||||
function updateClaudeIconFeedback(
|
||||
message: string,
|
||||
iconElement: HTMLElement,
|
||||
|
|
|
|||
445
apps/browser-extension/entrypoints/content/grok.ts
Normal file
445
apps/browser-extension/entrypoints/content/grok.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
|
||||
import { DOMUtils } from "../../utils/ui-components"
|
||||
|
||||
let grokRouteObserver: MutationObserver | null = null
|
||||
let grokUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let grokObserverThrottle: NodeJS.Timeout | null = null
|
||||
const GROK_IMPORT_INTENT_PARAM = "sm_grok_import"
|
||||
const GROK_IMPORT_INTENT_VALUE = "memories"
|
||||
|
||||
export function initializeGrok() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.GROK)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.body.hasAttribute("data-grok-initialized")) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToGrokMemoryDialog()
|
||||
handleGrokImportIntent()
|
||||
}, 1000)
|
||||
|
||||
setupGrokRouteChangeDetection()
|
||||
|
||||
document.body.setAttribute("data-grok-initialized", "true")
|
||||
}
|
||||
|
||||
function setupGrokRouteChangeDetection() {
|
||||
if (grokRouteObserver) {
|
||||
grokRouteObserver.disconnect()
|
||||
}
|
||||
if (grokUrlCheckInterval) {
|
||||
clearInterval(grokUrlCheckInterval)
|
||||
}
|
||||
if (grokObserverThrottle) {
|
||||
clearTimeout(grokObserverThrottle)
|
||||
grokObserverThrottle = null
|
||||
}
|
||||
|
||||
let currentUrl = window.location.href
|
||||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToGrokMemoryDialog()
|
||||
handleGrokImportIntent()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
grokUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
grokRouteObserver = new MutationObserver((mutations) => {
|
||||
if (grokObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
||||
let shouldRecheck = false
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type !== "childList" || mutation.addedNodes.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const element = node as Element
|
||||
const text = element.textContent || ""
|
||||
if (
|
||||
element.querySelector?.('[role="dialog"]') ||
|
||||
element.matches?.('[role="dialog"]') ||
|
||||
text.includes("Data Controls") ||
|
||||
text.includes("Settings") ||
|
||||
text.includes("Memory from your chats")
|
||||
) {
|
||||
shouldRecheck = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecheck) {
|
||||
grokObserverThrottle = setTimeout(() => {
|
||||
grokObserverThrottle = null
|
||||
addSupermemoryButtonToGrokMemoryDialog()
|
||||
handleGrokImportIntent()
|
||||
}, 250)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
grokRouteObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to set up Grok route observer:", error)
|
||||
if (grokUrlCheckInterval) {
|
||||
clearInterval(grokUrlCheckInterval)
|
||||
}
|
||||
grokUrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function hasGrokImportIntent() {
|
||||
return (
|
||||
new URLSearchParams(window.location.search).get(
|
||||
GROK_IMPORT_INTENT_PARAM,
|
||||
) === GROK_IMPORT_INTENT_VALUE
|
||||
)
|
||||
}
|
||||
|
||||
function clearGrokImportIntent() {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete(GROK_IMPORT_INTENT_PARAM)
|
||||
window.history.replaceState(window.history.state, "", url.toString())
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isVisible(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(element)
|
||||
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
Number.parseFloat(style.opacity || "1") > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getNormalizedText(element: Element) {
|
||||
return (element.textContent || "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function clickVisibleElementByText(
|
||||
labels: string[],
|
||||
root: ParentNode = document,
|
||||
) {
|
||||
const elements = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
"button, a, [role='button'], [role='tab'], [data-testid], div, span",
|
||||
),
|
||||
)
|
||||
|
||||
for (const label of labels) {
|
||||
const matchingElement = elements.find((element) => {
|
||||
const text = getNormalizedText(element)
|
||||
return text === label && isVisible(element)
|
||||
})
|
||||
|
||||
if (!matchingElement) {
|
||||
continue
|
||||
}
|
||||
|
||||
const clickableElement =
|
||||
matchingElement.closest<HTMLElement>(
|
||||
"button, a, [role='button'], [role='tab']",
|
||||
) || matchingElement
|
||||
|
||||
clickableElement.click()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getGrokSettingsDialog() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
).find((dialog) => {
|
||||
const text = getNormalizedText(dialog)
|
||||
return (
|
||||
isVisible(dialog) &&
|
||||
text.includes("Data Controls") &&
|
||||
text.includes("Appearance") &&
|
||||
text.includes("Behavior")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function isGrokDataControlsVisible() {
|
||||
const text = getNormalizedText(document.body)
|
||||
return (
|
||||
text.includes("Data Controls") && text.includes("Memory from your chats")
|
||||
)
|
||||
}
|
||||
|
||||
async function handleGrokImportIntent() {
|
||||
if (!hasGrokImportIntent()) return
|
||||
|
||||
if (document.body.hasAttribute("data-grok-import-intent-running")) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.setAttribute("data-grok-import-intent-running", "true")
|
||||
|
||||
for (let attempt = 0; attempt < 24; attempt++) {
|
||||
addSupermemoryButtonToGrokMemoryDialog()
|
||||
|
||||
if (getGrokMemoryDialog()) {
|
||||
clearGrokImportIntent()
|
||||
document.body.removeAttribute("data-grok-import-intent-running")
|
||||
return
|
||||
}
|
||||
|
||||
const settingsDialog = getGrokSettingsDialog()
|
||||
if (settingsDialog) {
|
||||
if (isGrokDataControlsVisible()) {
|
||||
clearGrokImportIntent()
|
||||
document.body.removeAttribute("data-grok-import-intent-running")
|
||||
return
|
||||
}
|
||||
|
||||
clickVisibleElementByText(["Data Controls"], settingsDialog)
|
||||
} else {
|
||||
clickVisibleElementByText(["Settings"], document)
|
||||
}
|
||||
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
document.body.removeAttribute("data-grok-import-intent-running")
|
||||
}
|
||||
|
||||
function getGrokMemoryDialog(): HTMLElement | null {
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
|
||||
)
|
||||
|
||||
for (const dialog of dialogs) {
|
||||
const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find(
|
||||
(element) => element.textContent?.trim() === "Memory from your chats",
|
||||
)
|
||||
if (heading) return dialog
|
||||
}
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll<HTMLElement>("div"))
|
||||
.filter((element) => {
|
||||
const text = element.textContent || ""
|
||||
if (
|
||||
!text.includes("Memory from your chats") ||
|
||||
!text.includes("This summary is regenerated")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 400 && rect.height > 250
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect()
|
||||
const rectB = b.getBoundingClientRect()
|
||||
return rectA.width * rectA.height - rectB.width * rectB.height
|
||||
})
|
||||
|
||||
return candidates[0] || null
|
||||
}
|
||||
|
||||
const GROK_MEMORY_UI_TEXT = [
|
||||
"Memory from your chats",
|
||||
"This summary is regenerated periodically from your conversations.",
|
||||
"Save to supermemory",
|
||||
"Close",
|
||||
"Delete memory",
|
||||
"Edit",
|
||||
] as const
|
||||
|
||||
function escapeRegExp(text: string) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
function sanitizeGrokMemoryText(text: string) {
|
||||
let sanitizedText = text
|
||||
|
||||
for (const uiText of GROK_MEMORY_UI_TEXT) {
|
||||
sanitizedText = sanitizedText.replace(
|
||||
new RegExp(escapeRegExp(uiText), "g"),
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
return sanitizedText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line)
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getGrokMemoryText(dialog: HTMLElement): string {
|
||||
const clonedDialog = dialog.cloneNode(true) as HTMLElement
|
||||
clonedDialog.querySelector("#supermemory-save-button")?.remove()
|
||||
|
||||
const possibleMemoryContainers = Array.from(
|
||||
clonedDialog.querySelectorAll<HTMLElement>(
|
||||
"article, section, [class*='overflow'], [class*='prose'], [class*='whitespace']",
|
||||
),
|
||||
)
|
||||
.map((element) => element.innerText || element.textContent || "")
|
||||
.map(sanitizeGrokMemoryText)
|
||||
.filter((text) => text.length > 30)
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
if (possibleMemoryContainers[0]) {
|
||||
return possibleMemoryContainers[0]
|
||||
}
|
||||
|
||||
return sanitizeGrokMemoryText(
|
||||
clonedDialog.innerText || clonedDialog.textContent || "",
|
||||
)
|
||||
}
|
||||
|
||||
function createSupermemoryButton(memoryDialog: HTMLElement) {
|
||||
const supermemoryButton = document.createElement("button")
|
||||
supermemoryButton.id = "supermemory-save-button"
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
|
||||
supermemoryButton.innerHTML = `
|
||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
|
||||
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
|
||||
<span style="white-space: nowrap;">Save to supermemory</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
supermemoryButton.style.cssText = `
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: auto !important;
|
||||
min-width: 190px !important;
|
||||
background: #1C2026 !important;
|
||||
color: white !important;
|
||||
border: 1px solid #1C2026 !important;
|
||||
border-radius: 9999px !important;
|
||||
padding: 10px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 20px !important;
|
||||
white-space: nowrap !important;
|
||||
cursor: pointer !important;
|
||||
font-family: inherit !important;
|
||||
z-index: 1 !important;
|
||||
`
|
||||
|
||||
supermemoryButton.addEventListener("mouseenter", () => {
|
||||
supermemoryButton.style.backgroundColor = "#2B2E33"
|
||||
})
|
||||
|
||||
supermemoryButton.addEventListener("mouseleave", () => {
|
||||
supermemoryButton.style.backgroundColor = "#1C2026"
|
||||
})
|
||||
|
||||
supermemoryButton.addEventListener("click", async () => {
|
||||
await saveGrokMemoriesToSupermemory(memoryDialog)
|
||||
})
|
||||
|
||||
return supermemoryButton
|
||||
}
|
||||
|
||||
function addSupermemoryButtonToGrokMemoryDialog() {
|
||||
const memoryDialog = getGrokMemoryDialog()
|
||||
if (!memoryDialog) return
|
||||
|
||||
if (memoryDialog.querySelector("#supermemory-save-button")) return
|
||||
|
||||
const supermemoryButton = createSupermemoryButton(memoryDialog)
|
||||
|
||||
const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find(
|
||||
(element) => element.textContent?.trim() === "Memory from your chats",
|
||||
)
|
||||
|
||||
const closeButton = Array.from(
|
||||
memoryDialog.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => {
|
||||
const label = button.getAttribute("aria-label")?.toLowerCase() || ""
|
||||
const text = button.textContent?.trim().toLowerCase() || ""
|
||||
return label.includes("close") || text === "×" || text === "x"
|
||||
})
|
||||
|
||||
if (heading?.parentElement) {
|
||||
const header = heading.parentElement
|
||||
header.style.display = "flex"
|
||||
header.style.alignItems = "center"
|
||||
header.style.gap = "12px"
|
||||
|
||||
const spacer = document.createElement("div")
|
||||
spacer.style.flex = "1"
|
||||
|
||||
if (closeButton?.parentElement === header) {
|
||||
header.insertBefore(spacer, closeButton)
|
||||
header.insertBefore(supermemoryButton, closeButton)
|
||||
} else {
|
||||
header.appendChild(spacer)
|
||||
header.appendChild(supermemoryButton)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (closeButton?.parentElement) {
|
||||
closeButton.parentElement.insertBefore(supermemoryButton, closeButton)
|
||||
return
|
||||
}
|
||||
|
||||
memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild)
|
||||
}
|
||||
|
||||
async function saveGrokMemoriesToSupermemory(memoryDialog: HTMLElement) {
|
||||
try {
|
||||
DOMUtils.showToast("loading")
|
||||
|
||||
const memoryText = getGrokMemoryText(memoryDialog)
|
||||
if (!memoryText) {
|
||||
DOMUtils.showToast("error")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
data: {
|
||||
content: memoryText,
|
||||
title: "Grok memories import",
|
||||
},
|
||||
actionSource: "grok_memories_dialog",
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
DOMUtils.showToast("success")
|
||||
} else {
|
||||
DOMUtils.showToast("error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving Grok memories to supermemory:", error)
|
||||
DOMUtils.showToast("error")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants"
|
|||
import { DOMUtils } from "../../utils/ui-components"
|
||||
import { initializeChatGPT } from "./chatgpt"
|
||||
import { initializeClaude } from "./claude"
|
||||
import { initializeGrok } from "./grok"
|
||||
import {
|
||||
saveMemory,
|
||||
setupGlobalKeyboardShortcut,
|
||||
|
|
@ -48,6 +49,9 @@ export default defineContentScript({
|
|||
if (DOMUtils.isOnDomain(DOMAINS.CLAUDE)) {
|
||||
initializeClaude()
|
||||
}
|
||||
if (DOMUtils.isOnDomain(DOMAINS.GROK)) {
|
||||
initializeGrok()
|
||||
}
|
||||
if (DOMUtils.isOnDomain(DOMAINS.T3)) {
|
||||
initializeT3()
|
||||
}
|
||||
|
|
@ -65,6 +69,7 @@ export default defineContentScript({
|
|||
// Initialize platform-specific functionality
|
||||
initializeChatGPT()
|
||||
initializeClaude()
|
||||
initializeGrok()
|
||||
initializeT3()
|
||||
initializeTwitter()
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,167 @@ const Tooltip = ({
|
|||
)
|
||||
}
|
||||
|
||||
const cardShadow =
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset"
|
||||
|
||||
type ManualImportProvider = "gemini"
|
||||
|
||||
const manualImportProviderConfig: Record<
|
||||
ManualImportProvider,
|
||||
{ label: string; actionSource: string }
|
||||
> = {
|
||||
gemini: {
|
||||
label: "Gemini",
|
||||
actionSource: "gemini_manual_memory_import",
|
||||
},
|
||||
}
|
||||
|
||||
const manualMemoryImportPrompt = `Export all of my stored memories and any context you've learned about me from past conversations. Preserve my words verbatim where possible, especially for instructions and preferences.
|
||||
|
||||
## Categories (output in this order):
|
||||
|
||||
1. **Instructions**: Rules I've explicitly asked you to follow going forward - tone, format, style, "always do X", "never do Y", and corrections to your behavior. Only include rules from stored memories, not from conversations.
|
||||
|
||||
2. **Identity**: Name, age, location, education, family, relationships, languages, and personal interests.
|
||||
|
||||
3. **Career**: Current and past roles, companies, and general skill areas.
|
||||
|
||||
4. **Projects**: Projects I meaningfully built or committed to. Ideally ONE entry per project. Include what it does, current status, and any key decisions. Use the project name or a short descriptor as the first words of the entry.
|
||||
|
||||
5. **Preferences**: Opinions, tastes, and working-style preferences that apply broadly.
|
||||
|
||||
## Format:
|
||||
|
||||
Use section headers for each category. Within each category, list one entry per line, sorted by oldest date first. Format each line as:
|
||||
|
||||
[YYYY-MM-DD] - Entry content here.
|
||||
|
||||
If no date is known, use [unknown] instead.
|
||||
|
||||
## Output:
|
||||
- Wrap the entire export in a single code block for easy copying.
|
||||
- After the code block, state whether this is the complete set or if more remain.`
|
||||
|
||||
const normalizeManualMemoryImport = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
const codeBlockMatch = trimmed.match(/```(?:[\w-]+)?\s*([\s\S]*?)```/)
|
||||
|
||||
return (codeBlockMatch?.[1] ?? trimmed).trim()
|
||||
}
|
||||
|
||||
const OpenAILogo = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
aria-label="ChatGPT Logo"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClaudeLogo = ({ className }: { className?: string }) => (
|
||||
<img alt="Claude" className={className} src="./claude.png" />
|
||||
)
|
||||
|
||||
const GeminiLogo = ({ className }: { className?: string }) => (
|
||||
<img alt="Gemini" className={className} src="./gemini.png" />
|
||||
)
|
||||
|
||||
const XLogo = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
aria-label="X Twitter Logo"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>X Twitter Logo</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GrokLogo = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
aria-label="Grok Logo"
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Grok</title>
|
||||
<path
|
||||
d="M17.85 6.35A7.3 7.3 0 0 0 6.2 14.75"
|
||||
stroke="white"
|
||||
strokeLinecap="square"
|
||||
strokeWidth="2.7"
|
||||
/>
|
||||
<path
|
||||
d="M6.15 17.65A7.3 7.3 0 0 0 17.8 9.25"
|
||||
stroke="white"
|
||||
strokeLinecap="square"
|
||||
strokeWidth="2.7"
|
||||
/>
|
||||
<path
|
||||
d="M3.8 20.2L20.2 3.8"
|
||||
stroke="white"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="2.4"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChatAppsLogo = ({ className }: { className?: string }) => (
|
||||
<div className={`relative h-5 w-[42px] shrink-0 ${className || ""}`}>
|
||||
<div className="absolute left-0 top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#214E54] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
|
||||
<OpenAILogo className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div className="absolute left-[13px] top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#2A1710] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
|
||||
<ClaudeLogo className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="absolute left-[26px] top-0 flex h-5 w-5 items-center justify-center rounded-[7px] border border-[#FFFFFF1A] bg-[#111820] shadow-[0_0_0_1px_rgba(0,0,0,0.35),0_4px_12px_rgba(0,0,0,0.25)]">
|
||||
<GrokLogo className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ImportCard = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-between gap-3 transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
}}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-left min-w-0">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="m-0 text-[14px] text-[#737373] leading-tight">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<RightArrow className="size-4 shrink-0 mt-1" />
|
||||
</button>
|
||||
)
|
||||
|
||||
function App() {
|
||||
const [userSignedIn, setUserSignedIn] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
|
|
@ -80,6 +241,14 @@ function App() {
|
|||
const [activeTab, setActiveTab] = useState<"save" | "imports" | "settings">(
|
||||
"save",
|
||||
)
|
||||
const [showChatAppImports, setShowChatAppImports] = useState<boolean>(false)
|
||||
const [manualImportProvider, setManualImportProvider] =
|
||||
useState<ManualImportProvider | null>(null)
|
||||
const [manualImportText, setManualImportText] = useState<string>("")
|
||||
const [manualImportSaving, setManualImportSaving] = useState<boolean>(false)
|
||||
const [manualImportSaved, setManualImportSaved] = useState<boolean>(false)
|
||||
const [manualImportCopied, setManualImportCopied] = useState<boolean>(false)
|
||||
const [manualImportError, setManualImportError] = useState<string>("")
|
||||
const [autoSearchEnabled, setAutoSearchEnabled] = useState<boolean>(false)
|
||||
const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] =
|
||||
useState<boolean>(false)
|
||||
|
|
@ -175,16 +344,33 @@ function App() {
|
|||
setShowProjectSelector(true)
|
||||
}
|
||||
|
||||
// Reconcile stored default against live list: reset if deleted, refresh if renamed.
|
||||
useEffect(() => {
|
||||
if (!defaultProject && projects.length > 0) {
|
||||
const firstProject = projects[0]
|
||||
setDefaultProjectMutation.mutate(firstProject)
|
||||
if (projects.length === 0) return
|
||||
if (!defaultProject) {
|
||||
setDefaultProjectMutation.mutate(projects[0])
|
||||
return
|
||||
}
|
||||
const live = projects.find((p) => p.id === defaultProject.id)
|
||||
if (!live) {
|
||||
setDefaultProjectMutation.mutate(projects[0])
|
||||
} else if (
|
||||
live.name !== defaultProject.name ||
|
||||
live.containerTag !== defaultProject.containerTag
|
||||
) {
|
||||
setDefaultProjectMutation.mutate(live)
|
||||
}
|
||||
}, [defaultProject, projects, setDefaultProjectMutation])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes
|
||||
useEffect(() => {
|
||||
setShowProjectSelector(false)
|
||||
setShowChatAppImports(false)
|
||||
setManualImportProvider(null)
|
||||
setManualImportText("")
|
||||
setManualImportSaved(false)
|
||||
setManualImportCopied(false)
|
||||
setManualImportError("")
|
||||
}, [activeTab])
|
||||
|
||||
const handleSaveCurrentPage = async () => {
|
||||
|
|
@ -252,6 +438,125 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleTwitterBookmarksImport = async () => {
|
||||
const targetUrl = "https://x.com/i/bookmarks"
|
||||
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
|
||||
const isOnBookmarksPage =
|
||||
activeTab?.url?.includes("x.com/i/bookmarks") ||
|
||||
activeTab?.url?.includes("twitter.com/i/bookmarks")
|
||||
|
||||
if (isOnBookmarksPage && activeTab?.id) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(activeTab.id, {
|
||||
action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to send message to content script:", error)
|
||||
const intentExpiry = Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]: intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const intentExpiry = Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]: intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error opening Twitter import:", error)
|
||||
try {
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
console.error("Failed to open bookmarks page:", fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenManualMemoryImport = (provider: ManualImportProvider) => {
|
||||
setManualImportProvider(provider)
|
||||
setManualImportText("")
|
||||
setManualImportSaved(false)
|
||||
setManualImportCopied(false)
|
||||
setManualImportError("")
|
||||
}
|
||||
|
||||
const handleCloseManualMemoryImport = () => {
|
||||
setManualImportProvider(null)
|
||||
setManualImportText("")
|
||||
setManualImportSaved(false)
|
||||
setManualImportCopied(false)
|
||||
setManualImportError("")
|
||||
}
|
||||
|
||||
const handleCopyManualImportPrompt = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(manualMemoryImportPrompt)
|
||||
setManualImportCopied(true)
|
||||
window.setTimeout(() => setManualImportCopied(false), 1600)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy memory import prompt:", error)
|
||||
setManualImportError(
|
||||
"Could not copy prompt. Select and copy it manually.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualMemoryImportSave = async () => {
|
||||
if (!manualImportProvider) return
|
||||
|
||||
const content = normalizeManualMemoryImport(manualImportText)
|
||||
if (!content) {
|
||||
setManualImportError("Paste the exported memories first.")
|
||||
return
|
||||
}
|
||||
|
||||
setManualImportSaving(true)
|
||||
setManualImportError("")
|
||||
|
||||
try {
|
||||
const providerConfig = manualImportProviderConfig[manualImportProvider]
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
actionSource: providerConfig.actionSource,
|
||||
data: {
|
||||
content,
|
||||
title: `${providerConfig.label} memories import`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || "Could not add memories")
|
||||
}
|
||||
|
||||
setManualImportSaved(true)
|
||||
window.setTimeout(() => {
|
||||
handleCloseManualMemoryImport()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error("Failed to add manual memory import:", error)
|
||||
setManualImportError(
|
||||
error instanceof Error ? error.message : "Could not add memories",
|
||||
)
|
||||
} finally {
|
||||
setManualImportSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
|
|
@ -630,136 +935,238 @@ function App() {
|
|||
</div>
|
||||
) : activeTab === "imports" ? (
|
||||
<div className="flex flex-col gap-4 min-h-[200px]">
|
||||
{/* Import Actions */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 hover:bg-[#5B7EF520]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
{manualImportProvider ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="m-0 text-base font-semibold text-white">
|
||||
Import{" "}
|
||||
{
|
||||
manualImportProviderConfig[manualImportProvider]
|
||||
.label
|
||||
}{" "}
|
||||
memories
|
||||
</h3>
|
||||
<p className="m-0 mt-1 text-xs leading-tight text-[#737373]">
|
||||
Copy the prompt, paste the response here, then add it
|
||||
to supermemory.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close manual import"
|
||||
className="shrink-0 bg-transparent border-none cursor-pointer p-1 text-[#737373] transition-colors hover:text-white"
|
||||
onClick={handleCloseManualMemoryImport}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="18"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black text-xs">
|
||||
1
|
||||
</span>
|
||||
<span>Copy this prompt into chat</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl bg-black/70 p-3"
|
||||
style={{ boxShadow: cardShadow }}
|
||||
>
|
||||
<pre className="m-0 max-h-28 overflow-y-auto whitespace-pre-wrap pb-9 pr-1 text-xs leading-snug text-[#B7B7B7] font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif]">
|
||||
{manualMemoryImportPrompt}
|
||||
</pre>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-linear-to-t from-black/80 to-transparent" />
|
||||
<button
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-lg border-none bg-[#FFFFFF1A] px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-[#FFFFFF26]"
|
||||
onClick={handleCopyManualImportPrompt}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="14"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
>
|
||||
<rect
|
||||
height="14"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="14"
|
||||
x="8"
|
||||
y="8"
|
||||
/>
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
{manualImportCopied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black text-xs">
|
||||
2
|
||||
</span>
|
||||
<span>Paste results below</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="min-h-32 w-full resize-none rounded-xl border border-[#FFFFFF14] bg-[#FFFFFF08] p-3 text-sm leading-snug text-white outline-none placeholder:text-[#737373] focus:border-[#5B7EF566]"
|
||||
onChange={(event) => {
|
||||
setManualImportText(event.target.value)
|
||||
setManualImportError("")
|
||||
}}
|
||||
placeholder="Paste your memory details here"
|
||||
value={manualImportText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{manualImportError && (
|
||||
<p className="m-0 text-xs leading-tight text-red-300">
|
||||
{manualImportError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
className="rounded-lg border-none bg-transparent px-4 py-2 text-sm font-medium text-[#8A8C90] transition-colors hover:bg-[#FFFFFF0D] hover:text-white"
|
||||
onClick={handleCloseManualMemoryImport}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border-none px-3.5 py-2 text-xs font-medium text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-80"
|
||||
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)",
|
||||
}}
|
||||
disabled={manualImportSaving || manualImportSaved}
|
||||
onClick={handleManualMemoryImportSave}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
{manualImportSaved ? (
|
||||
"Done"
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-4 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 20 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.4295 6.3108H12.1691V0H9.82324V6.84734C9.82324 7.57459 10.1103 8.27304 10.6206 8.78766L16.549 14.7664L18.2077 13.0936L13.8291 8.6779H19.4309V6.31219L19.4295 6.3108Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1.08945 2.90808L5.46808 7.32387H-0.133789V9.68958H7.12669V16.0003H9.4725V9.15304C9.4725 8.42574 9.18541 7.72728 8.67512 7.21272L2.74809 1.23535L1.08945 2.90808Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
{manualImportSaving
|
||||
? "Saving..."
|
||||
: "Save to supermemory"}
|
||||
</>
|
||||
)}
|
||||
{manualImportSaved && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="14"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2.4"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showChatAppImports ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ImportCard
|
||||
icon={<ClaudeLogo className="w-4 h-4 shrink-0" />}
|
||||
title="Import Claude Memories"
|
||||
description="Open 'view and manage' > save your memories to supermemory"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: "https://claude.ai/settings/capabilities",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ImportCard
|
||||
icon={<OpenAILogo className="w-3 h-3.5 shrink-0" />}
|
||||
title="Import ChatGPT Memories"
|
||||
description="Open 'manage' > save your memories to supermemory"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: "https://chatgpt.com/#settings/Personalization",
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<svg
|
||||
aria-label="ChatGPT Logo"
|
||||
className="w-3 h-3.5 shrink-0"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
Import ChatGPT Memories
|
||||
</p>
|
||||
<p className="m-0 text-[14px] text-[#737373] leading-tight">
|
||||
open 'manage' > save your memories to supermemory
|
||||
</p>
|
||||
</div>
|
||||
<RightArrow className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="w-full p-4 bg-[#5B7EF50A] text-white border-none rounded-xl text-sm cursor-pointer flex items-start justify-start transition-colors duration-200 outline-none appearance-none hover:bg-[#5B7EF520] focus:outline-none"
|
||||
style={{
|
||||
boxShadow:
|
||||
"2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset",
|
||||
/>
|
||||
<ImportCard
|
||||
icon={<GrokLogo className="w-4 h-4 shrink-0" />}
|
||||
title="Import Grok Memories"
|
||||
description="Open 'Memory from your chats' > save your memories to supermemory"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: "https://grok.com/?_s=data&sm_grok_import=memories",
|
||||
})
|
||||
}}
|
||||
onClick={async () => {
|
||||
const targetUrl = "https://x.com/i/bookmarks"
|
||||
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
|
||||
const isOnBookmarksPage =
|
||||
activeTab?.url?.includes("x.com/i/bookmarks") ||
|
||||
activeTab?.url?.includes("twitter.com/i/bookmarks")
|
||||
|
||||
if (isOnBookmarksPage && activeTab?.id) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(activeTab.id, {
|
||||
action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL,
|
||||
})
|
||||
} catch (error) {
|
||||
// Content script may not be loaded yet, fall back to intent-based approach
|
||||
console.error(
|
||||
"Failed to send message to content script:",
|
||||
error,
|
||||
)
|
||||
const intentExpiry =
|
||||
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
|
||||
intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const intentExpiry =
|
||||
Date.now() + UI_CONFIG.IMPORT_INTENT_TTL
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]:
|
||||
intentExpiry,
|
||||
})
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error opening Twitter import:", error)
|
||||
// Fallback: try to open the bookmarks page anyway
|
||||
try {
|
||||
await chrome.tabs.create({
|
||||
url: targetUrl,
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
"Failed to open bookmarks page:",
|
||||
fallbackError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<svg
|
||||
aria-label="X Twitter Logo"
|
||||
className="w-3 h-3.5 shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>X Twitter Logo</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Import X/Twitter Bookmarks
|
||||
</p>
|
||||
<p className="m-0 text-[14px] text-[#737373] leading-tight">
|
||||
Opens import dialog automatically
|
||||
</p>
|
||||
</div>
|
||||
<RightArrow className="size-4" />
|
||||
</button>
|
||||
/>
|
||||
<ImportCard
|
||||
icon={
|
||||
<GeminiLogo className="w-4 h-4 shrink-0 rounded-[4px]" />
|
||||
}
|
||||
title="Import Gemini Memories"
|
||||
description="Paste memories exported from Gemini chat"
|
||||
onClick={() => handleOpenManualMemoryImport("gemini")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ImportCard
|
||||
icon={<ChatAppsLogo />}
|
||||
title="Import Chat Memories"
|
||||
description="Import your ChatGPT, Claude, Grok, and Gemini memories"
|
||||
onClick={() => setShowChatAppImports(true)}
|
||||
/>
|
||||
<ImportCard
|
||||
icon={<XLogo className="w-3 h-3.5 shrink-0" />}
|
||||
title="Import X/Twitter Bookmarks"
|
||||
description="Opens import dialog automatically"
|
||||
onClick={handleTwitterBookmarksImport}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[200px] pl-1">
|
||||
|
|
|
|||
BIN
apps/browser-extension/public/claude.png
Normal file
BIN
apps/browser-extension/public/claude.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/browser-extension/public/gemini.png
Normal file
BIN
apps/browser-extension/public/gemini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
|
|
@ -4,5 +4,6 @@
|
|||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["chrome"]
|
||||
}
|
||||
},
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { API_ENDPOINTS } from "./constants"
|
||||
import { bearerToken, defaultProject, userData } from "./storage"
|
||||
import { buildSearchMemoriesBody } from "./search-request"
|
||||
import {
|
||||
AuthenticationError,
|
||||
type MemoryPayload,
|
||||
|
|
@ -145,14 +146,14 @@ export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
|
|||
/**
|
||||
* Search memories using Supermemory API
|
||||
*/
|
||||
export async function searchMemories(query: string): Promise<unknown> {
|
||||
export async function searchMemories(
|
||||
query: string,
|
||||
containerTag?: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
include: { relatedMemories: true },
|
||||
}),
|
||||
body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)),
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export const DOMAINS = {
|
|||
TWITTER: ["x.com", "twitter.com"],
|
||||
CHATGPT: ["chatgpt.com", "chat.openai.com"],
|
||||
CLAUDE: ["claude.ai"],
|
||||
GROK: ["grok.com", "x.ai"],
|
||||
T3: ["t3.chat"],
|
||||
SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
|
||||
} as const
|
||||
|
|
|
|||
19
apps/browser-extension/utils/search-request.test.ts
Normal file
19
apps/browser-extension/utils/search-request.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { buildSearchMemoriesBody } from "./search-request"
|
||||
|
||||
describe("buildSearchMemoriesBody", () => {
|
||||
it("builds the default related-memory search body", () => {
|
||||
expect(buildSearchMemoriesBody("deploy notes")).toEqual({
|
||||
q: "deploy notes",
|
||||
include: { relatedMemories: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("includes the container tag when provided", () => {
|
||||
expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({
|
||||
q: "deploy notes",
|
||||
include: { relatedMemories: true },
|
||||
containerTag: "sm_project_docs",
|
||||
})
|
||||
})
|
||||
})
|
||||
14
apps/browser-extension/utils/search-request.ts
Normal file
14
apps/browser-extension/utils/search-request.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function buildSearchMemoriesBody(
|
||||
query: string,
|
||||
containerTag?: string,
|
||||
): {
|
||||
q: string
|
||||
include: { relatedMemories: boolean }
|
||||
containerTag?: string
|
||||
} {
|
||||
return {
|
||||
q: query,
|
||||
include: { relatedMemories: true },
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export default defineConfig({
|
|||
manifest: {
|
||||
name: "supermemory",
|
||||
homepage_url: "https://supermemory.ai",
|
||||
version: "6.1.3",
|
||||
version: "6.1.4",
|
||||
permissions: ["storage", "activeTab", "webRequest", "tabs"],
|
||||
host_permissions: [
|
||||
"*://x.com/*",
|
||||
|
|
@ -38,6 +38,10 @@ export default defineConfig({
|
|||
"*://api.supermemory.ai/*",
|
||||
"*://chatgpt.com/*",
|
||||
"*://chat.openai.com/*",
|
||||
"*://grok.com/*",
|
||||
"*://*.grok.com/*",
|
||||
"*://x.ai/*",
|
||||
"*://*.x.ai/*",
|
||||
"https://*.posthog.com/*",
|
||||
],
|
||||
web_accessible_resources: [
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ Upload PDFs, images, and documents directly.
|
|||
| `metadata` | object | Key-value pairs for filtering (strings, numbers, booleans) |
|
||||
| `filterByMetadata` | object | Filter which existing memories are used as context during ingestion. See [Filtered Writes](#filtered-writes) |
|
||||
| `entityContext` | string | Context for memory extraction on this container tag. Max 1500 chars. See [Customization](/concepts/customization#entity-context) |
|
||||
| `dreaming` | `"dynamic" \| "instant"` | Processing mode. Default `"dynamic"`. `"instant"` processes each document on its own and bills one extra operation. See [Processing Modes](#processing-modes) |
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Parameter Details & Examples">
|
||||
|
|
@ -277,6 +278,22 @@ Upload PDFs, images, and documents directly.
|
|||
|
||||
---
|
||||
|
||||
## Processing Modes
|
||||
|
||||
The `dreaming` parameter controls how Supermemory turns a document into memories.
|
||||
|
||||
- `"dynamic"` (default) — groups related documents together so memories form from coherent, logical units rather than one isolated entry at a time.
|
||||
- `"instant"` — processes each document on its own right away, and bills one extra operation per document.
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "...",
|
||||
"dreaming": "instant"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Filtered Writes
|
||||
|
||||
By default, when you add content, Supermemory uses **all** existing memories in the space as context for generating new memories. With **filtered writes**, you can scope this context to only memories from documents matching specific metadata.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
---
|
||||
title: "Changelog"
|
||||
sidebarTitle: "Supermemory"
|
||||
description: "New updates and improvements to Supermemory"
|
||||
---
|
||||
|
||||
<Update label="May 27, 2026" tags={["API"]}>
|
||||
|
||||
### Instant dreaming
|
||||
|
||||
New `dreaming` parameter on `POST /v3/documents` and `POST /v3/documents/batch`. Default `"dynamic"` groups related documents together so memories form from coherent, logical units. Set `"dreaming": "instant"` to process a single document on its own — bills one extra operation per document. Omit the parameter and behavior is unchanged.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="April 13, 2026" tags={["Integrations", "API"]}>
|
||||
|
||||
### Google Drive: scoped sync by default
|
||||
|
|
|
|||
81
apps/docs/changelog/plugins.mdx
Normal file
81
apps/docs/changelog/plugins.mdx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
title: "Plugin changelog"
|
||||
sidebarTitle: "Plugins"
|
||||
description: "Recent updates and improvements to Supermemory plugins"
|
||||
---
|
||||
|
||||
<Update label="June 20, 2026" tags={["OpenCode", "Cursor"]}>
|
||||
|
||||
### OpenCode entity context
|
||||
|
||||
OpenCode now sends entity context with memory operations, so saved context can stay tied to the active project and conversation. The entity-context prompt was also moved out of the API client for cleaner reuse across capture and compaction flows.
|
||||
|
||||
### Cursor session auth
|
||||
|
||||
Cursor now starts the auth flow from the session hook when needed, and the OAuth success screen uses the Cursor-branded callback path.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="June 18, 2026" tags={["Claude Code", "OpenCode"]}>
|
||||
|
||||
### Claude Code update notices
|
||||
|
||||
Claude Code now surfaces plugin update notices during sessions and includes the latest package/version metadata.
|
||||
|
||||
### OpenCode context prompt
|
||||
|
||||
OpenCode gained an entity-context prompt so memory recall and capture can carry more precise source context.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="June 13, 2026" tags={["Claude Code", "Codex"]}>
|
||||
|
||||
### Claude Code marketplace polish
|
||||
|
||||
The Claude Code plugin manifest was polished for the official marketplace listing, including refreshed metadata and naming.
|
||||
|
||||
### Codex update notices
|
||||
|
||||
Codex now checks for plugin updates during session start and shows a user-visible notice when a newer version is available.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="June 11, 2026" tags={["Claude Code", "Cursor"]}>
|
||||
|
||||
### Claude Code rename migration
|
||||
|
||||
Claude Code completed the rename to the `supermemory` plugin while keeping migration safe for users already on the new plugin name. Configuration also supports custom `baseUrl` values for local or self-hosted Supermemory installs.
|
||||
|
||||
### Cursor web OAuth
|
||||
|
||||
Cursor OAuth now routes through the Supermemory web app, keeping the plugin auth flow consistent with the rest of the integrations.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="June 10, 2026" tags={["Codex", "OpenCode"]}>
|
||||
|
||||
### Codex auth and status tooling
|
||||
|
||||
Codex added status, logout, and web-auth flows, plus Windows-safe auth URL opening and entity context for saved memories. The installer now includes a `supermemory-status` skill so Codex can report connection, hook, config, and installed-skill health from inside a session.
|
||||
|
||||
### OAuth status refinements
|
||||
|
||||
Codex and OpenCode integration status now renders more clearly in the Supermemory app during OAuth connection and setup.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="June 6, 2026" tags={["Claude Code", "Cursor", "OpenClaw", "Hermes"]}>
|
||||
|
||||
### Claude Code recall reasoning
|
||||
|
||||
Claude Code gained reasoned per-turn memory recall with auto-approve support, refreshed bundled scripts, and updated skill names for `supermemory-save` and `supermemory-search`.
|
||||
|
||||
### Cursor session hooks
|
||||
|
||||
Cursor session hooks now load reliably and persist real project sessions into the correct container.
|
||||
|
||||
### OpenClaw and Hermes memory attribution
|
||||
|
||||
Saved plugin memories now parse source attribution more accurately, and the dashboard shows the correct plugin logos and recent-memory rows for OpenClaw and Hermes.
|
||||
|
||||
</Update>
|
||||
176
apps/docs/concepts/container-tags.mdx
Normal file
176
apps/docs/concepts/container-tags.mdx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
title: "Container Tags"
|
||||
sidebarTitle: "Container Tags"
|
||||
description: "The isolation boundary that groups and partitions memories by user, project, or any logical scope"
|
||||
icon: "folder"
|
||||
---
|
||||
|
||||
A **container tag** is the primary way you organize and isolate memories in Supermemory. It's a simple string identifier you attach to content when you add it — and that you pass back when you search, list, or update it.
|
||||
|
||||
Think of a container tag as a **namespace**: every memory tagged with `user_alex` lives in its own isolated space, completely separate from memories tagged `user_jordan`. This is what makes Supermemory safe to use in multi-tenant applications — one user can never see another user's memories unless you explicitly query across both tags.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Group" icon="layers">
|
||||
Bucket memories by user, project, agent, workspace, or any boundary that makes sense for your app.
|
||||
</Card>
|
||||
<Card title="Isolate" icon="shield">
|
||||
Each container tag maps to its own vector namespace, so search and retrieval never leak across boundaries.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
When you add a memory with a container tag, Supermemory automatically creates a **space** for that tag (scoped to your organization) if one doesn't already exist. You don't need to provision anything ahead of time — the first write with a new tag creates the container, and subsequent writes reuse it.
|
||||
|
||||
```typescript
|
||||
// First call auto-creates the "user_alex" container
|
||||
await client.add({
|
||||
content: "Alex prefers dark mode and concise answers",
|
||||
containerTag: "user_alex",
|
||||
});
|
||||
|
||||
// Later, retrieve only Alex's memories
|
||||
const results = await client.search.memories({
|
||||
q: "what are the user's UI preferences?",
|
||||
containerTag: "user_alex",
|
||||
});
|
||||
```
|
||||
|
||||
Under the hood, each container tag is hashed into a dedicated vector namespace. Embeddings, chunks, and memory entries for one tag are stored and searched independently of every other tag — there is no shared index to filter through, which is why isolation is strict rather than best-effort.
|
||||
|
||||
<Note>
|
||||
A container tag is an **opaque identifier you choose**. Supermemory does not parse meaning out of it — `user_123`, `project_mobile`, and `org:acme:team:growth` are all equally valid. Pick a convention that mirrors the access boundaries in your own application.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Naming rules
|
||||
|
||||
Container tags are validated on every request. A tag must:
|
||||
|
||||
- Be **100 characters or less**
|
||||
- Contain only **alphanumeric characters, hyphens (`-`), underscores (`_`), and colons (`:`)**
|
||||
|
||||
Matching pattern: `^[a-zA-Z0-9_:-]+$`
|
||||
|
||||
```typescript
|
||||
// ✅ Valid
|
||||
"user_123"
|
||||
"project-mobile-app"
|
||||
"org:acme:user:john"
|
||||
"tenant_42_workspace_7"
|
||||
|
||||
// ❌ Invalid — spaces, slashes, and other symbols are rejected
|
||||
"user 123"
|
||||
"project/mobile"
|
||||
"team@acme"
|
||||
```
|
||||
|
||||
The colon is intentionally allowed so you can build **hierarchical** tags (for example `org:acme:user:john`) that encode several levels of structure in a single identifier.
|
||||
|
||||
---
|
||||
|
||||
## `containerTag` vs `containerTags`
|
||||
|
||||
Supermemory's current API uses a **single** `containerTag` string per request.
|
||||
|
||||
<Warning>
|
||||
The plural `containerTags` array field is **deprecated**. It still works for backward compatibility on older (`/v3`) endpoints, but new integrations should use the singular `containerTag` string. The `/v4` API only accepts `containerTag`.
|
||||
</Warning>
|
||||
|
||||
| API field | Type | Status |
|
||||
|-----------|------|--------|
|
||||
| `containerTag` | `string` | ✅ Current — use this |
|
||||
| `containerTags` | `string[]` | ⚠️ Deprecated |
|
||||
|
||||
---
|
||||
|
||||
## Where container tags are used
|
||||
|
||||
The same tag flows through the entire lifecycle of a memory. Pass it consistently and your data stays neatly partitioned.
|
||||
|
||||
| Operation | Behavior |
|
||||
|-----------|----------|
|
||||
| **Add** | Writes the memory into the tag's container (auto-creating the space). |
|
||||
| **Search** | Restricts retrieval to the given tag's namespace. |
|
||||
| **List** | Returns only memories belonging to the tag(s). |
|
||||
| **Update / Delete** | Targets the memory inside the specified tag's container. |
|
||||
|
||||
```typescript
|
||||
// Add
|
||||
await client.add({ content: "Q1 planning notes", containerTag: "project_q1" });
|
||||
|
||||
// Search within the same container
|
||||
await client.search.memories({ q: "planning", containerTag: "project_q1" });
|
||||
|
||||
// List everything in the container
|
||||
await client.documents.list({ containerTags: ["project_q1"] });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access control
|
||||
|
||||
Container tags are also an **authorization boundary**, not just an organizational one. Two mechanisms can restrict which tags a given caller may touch:
|
||||
|
||||
- **API key scopes** — an API key can be limited to a specific set of container tags, with read or write permission per tag.
|
||||
- **Member restrictions** — an organization member can be granted access to only certain container tags.
|
||||
|
||||
When a request is restricted, Supermemory validates the requested tag against the caller's allowed set:
|
||||
|
||||
- Requesting a tag outside the allowed set returns `403 Forbidden`.
|
||||
- A write (add/update/delete) to a read-only tag returns `403 Forbidden`.
|
||||
- If no tag is supplied by a restricted caller, the request is automatically scoped to their allowed tag(s).
|
||||
|
||||
This means you can hand out an API key that is physically incapable of reading or writing another tenant's data, enforced at the data layer rather than in your application code.
|
||||
|
||||
---
|
||||
|
||||
## Per-container settings
|
||||
|
||||
Each container tag can carry its own configuration, independent of other tags in the same organization:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `name` | A human-friendly display name for the container. |
|
||||
| `entityContext` | A custom context prompt applied when processing documents in this container — useful for steering extraction and summarization per project or tenant. |
|
||||
|
||||
```typescript
|
||||
await client.containerTags.update("project_research", {
|
||||
entityContext: "This project contains research papers about machine learning.",
|
||||
});
|
||||
```
|
||||
|
||||
Container tags can also be **merged** when you need to consolidate two buckets of memories into one.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a convention
|
||||
|
||||
Pick a tagging scheme that maps onto the isolation boundaries your application actually needs.
|
||||
|
||||
| Pattern | Example | Use case |
|
||||
|---------|---------|----------|
|
||||
| User isolation | `user_{userId}` | Per-user memory in a consumer app |
|
||||
| Project grouping | `project_{projectId}` | Project- or workspace-scoped content |
|
||||
| Agent scoping | `agent_{agentId}` | Separate long-term memory per AI agent |
|
||||
| Hierarchical | `org:{orgId}:user:{userId}` | Multi-level, multi-tenant SaaS |
|
||||
|
||||
<Tip>
|
||||
Keep tags **deterministic** — derive them directly from IDs you already have (a user ID, a tenant ID) so you can always reconstruct the right tag at query time without a lookup.
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Organizing & Filtering" icon="filter" href="/concepts/filtering">
|
||||
Combine container tags with metadata filters for precise retrieval.
|
||||
</Card>
|
||||
<Card title="Adding Memories" icon="plus" href="/add-memories">
|
||||
See container tags in action across the add API.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
213
apps/docs/connectors/granola.mdx
Normal file
213
apps/docs/connectors/granola.mdx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
---
|
||||
title: "Granola Connector"
|
||||
description: "Sync AI meeting notes and transcripts from Granola into your Supermemory knowledge base"
|
||||
icon: "/images/granola.svg"
|
||||
---
|
||||
|
||||
Connect Granola to sync AI meeting notes and transcripts into your Supermemory knowledge base. The connector uses a Granola API key, so there is no OAuth redirect flow.
|
||||
|
||||
<Note>
|
||||
The Granola connector requires a **Pro Plan** or higher in Supermemory and a Granola plan that can create API keys. In Granola, create one from **Settings > Connectors > API keys**.
|
||||
</Note>
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### From the Console
|
||||
|
||||
1. Open the [Supermemory Console](https://console.supermemory.ai).
|
||||
2. Go to **Connectors**.
|
||||
3. Find the **Granola** row and click **Connect**.
|
||||
4. Paste your Granola API key.
|
||||
5. Optionally set a document limit and container tag.
|
||||
6. Click **Connect**.
|
||||
|
||||
The console creates the connection and starts the initial sync automatically.
|
||||
|
||||
<Note>
|
||||
The console limits connector setup to 500 documents. Use the API setup below for higher `documentLimit` values, up to 10,000.
|
||||
</Note>
|
||||
|
||||
### With the API
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
import Supermemory from 'supermemory';
|
||||
|
||||
const client = new Supermemory({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!
|
||||
});
|
||||
|
||||
const connection = await client.connections.create('granola', {
|
||||
metadata: {
|
||||
apiKey: process.env.GRANOLA_API_KEY!
|
||||
},
|
||||
containerTags: ['org-123', 'meeting-notes'],
|
||||
documentLimit: 1000
|
||||
});
|
||||
|
||||
console.log('Granola connection:', connection.id);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
import os
|
||||
|
||||
client = Supermemory(api_key=os.environ["SUPERMEMORY_API_KEY"])
|
||||
|
||||
connection = client.connections.create(
|
||||
'granola',
|
||||
metadata={
|
||||
'apiKey': os.environ["GRANOLA_API_KEY"]
|
||||
},
|
||||
container_tags=['org-123', 'meeting-notes'],
|
||||
document_limit=1000
|
||||
)
|
||||
|
||||
print(f'Granola connection: {connection.id}')
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v3/connections/granola" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"metadata": {
|
||||
"apiKey": "'"$GRANOLA_API_KEY"'"
|
||||
},
|
||||
"containerTags": ["org-123", "meeting-notes"],
|
||||
"documentLimit": 1000
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Supermemory validates the Granola API key before creating the connection. The initial sync starts automatically after the connection is created.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
For Granola, provider-specific fields are passed inside the top-level `metadata` object. General connection options stay top-level.
|
||||
|
||||
| Parameter | Location | Required | Description |
|
||||
|-----------|----------|----------|-------------|
|
||||
| `apiKey` | `metadata.apiKey` | Yes | Granola API key from **Settings > Connectors > API keys** |
|
||||
| `containerTags` | top-level | No | Tags for organizing imported notes by user, organization, project, or tenant |
|
||||
| `documentLimit` | top-level | No | Maximum notes to sync per connection (default: 10,000) |
|
||||
|
||||
<Note>
|
||||
In the Python SDK, use `container_tags` and `document_limit` for top-level options, but keep the Granola metadata key in camelCase: `apiKey`.
|
||||
</Note>
|
||||
|
||||
## What Gets Synced
|
||||
|
||||
Granola notes are synced as markdown documents. Each document can include:
|
||||
|
||||
- Note title
|
||||
- Meeting time, attendees, and meeting URL when Granola returns them
|
||||
- AI-generated summary when present
|
||||
- Full transcript when present
|
||||
|
||||
### Document Metadata
|
||||
|
||||
Each synced note includes searchable metadata:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `type` | Always `granola` |
|
||||
| `title` | Granola note title |
|
||||
| `createdAt` | Granola note creation timestamp |
|
||||
| `updatedAt` | Granola note update timestamp |
|
||||
| `url` | Granola note URL, when available |
|
||||
| `attendees` | Attendee names or emails, when available |
|
||||
| `meetingStart` | Calendar event start time, when available |
|
||||
|
||||
You can filter searches using these metadata fields:
|
||||
|
||||
```typescript
|
||||
const results = await client.search.documents({
|
||||
q: "customer onboarding discussion",
|
||||
containerTags: ['org-123'],
|
||||
filters: JSON.stringify({
|
||||
AND: [
|
||||
{ key: "type", value: "granola", negate: false },
|
||||
{ key: "attendees", value: "alex@company.com", negate: false }
|
||||
]
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Management
|
||||
|
||||
### Delete Connection
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
await client.connections.deleteByID('conn_granola_abc123');
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
client.connections.delete_by_id('conn_granola_abc123')
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X DELETE "https://api.supermemory.ai/v3/connections/conn_granola_abc123" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
<Warning>
|
||||
By default, deleting a connection removes all synced documents from Supermemory. To keep documents, pass `deleteDocuments=false` as a query parameter: `DELETE /v3/connections/:id?deleteDocuments=false`
|
||||
</Warning>
|
||||
|
||||
### Manual Sync
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
await client.connections.import('granola', {
|
||||
containerTags: ['org-123']
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
client.connections.import_(
|
||||
'granola',
|
||||
container_tags=['org-123']
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v3/connections/granola/import" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTags": ["org-123"]}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Sync Behavior
|
||||
|
||||
| Feature | Behavior |
|
||||
|---------|----------|
|
||||
| **Initial sync** | Fetches Granola notes up to `documentLimit` |
|
||||
| **Incremental sync** | Uses Granola `updated_at` timestamps to fetch notes changed since the previous sync |
|
||||
| **Transcript handling** | Fetches each note with transcript content included |
|
||||
| **Sync schedule** | Initial sync after connection creation + manual triggers |
|
||||
| **Document limit** | 10,000 notes per connection (default) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| `Granola API key is required` | Include a non-empty `metadata.apiKey` value when creating the connection |
|
||||
| `Granola API key is invalid` | Create a new key in Granola and reconnect |
|
||||
| `Could not reach Granola API` | Retry after checking Granola API availability and network access |
|
||||
| Missing notes | Check `documentLimit`; if the workspace has more notes than the limit, only notes up to the limit are imported |
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
title: "Connectors Overview"
|
||||
description: "Integrate Google Drive, Gmail, Notion, OneDrive, GitHub and Web Crawler to automatically sync documents into your knowledge base"
|
||||
description: "Integrate Google Drive, Gmail, Notion, OneDrive, GitHub, Granola and Web Crawler to automatically sync documents into your knowledge base"
|
||||
sidebarTitle: "Overview"
|
||||
icon: "layers"
|
||||
---
|
||||
|
||||
Connect external platforms to automatically sync documents into supermemory. Supported connectors include Google Drive, Gmail, Notion, OneDrive, GitHub and Web Crawler with real-time synchronization and intelligent content processing.
|
||||
Connect external platforms to automatically sync documents into supermemory. Supported connectors include Google Drive, Gmail, Notion, OneDrive, GitHub, Granola and Web Crawler with real-time synchronization and intelligent content processing.
|
||||
|
||||
## Supported Connectors
|
||||
|
||||
|
|
@ -41,6 +41,12 @@ Connect external platforms to automatically sync documents into supermemory. Sup
|
|||
Real-time incremental sync via webhooks. Supports documentation files in repositories.
|
||||
</Card>
|
||||
|
||||
<Card title="Granola" icon="/images/granola.svg" href="/connectors/granola">
|
||||
**Meeting notes and transcripts**
|
||||
|
||||
Syncs AI meeting notes, summaries, attendees, and transcripts from your Granola workspace.
|
||||
</Card>
|
||||
|
||||
<Card title="Web Crawler" icon="globe" href="/connectors/web-crawler">
|
||||
**Web Pages, Documentation**
|
||||
|
||||
|
|
@ -201,10 +207,10 @@ curl -X POST "https://api.supermemory.ai/v3/documents/list" \
|
|||
|
||||
### Authentication Flow
|
||||
|
||||
1. **Create Connection**: Call `/v3/connections/{provider}` to get OAuth URL (or direct connection for web-crawler)
|
||||
2. **User Authorization**: Redirect user to complete OAuth flow (not required for web-crawler)
|
||||
1. **Create Connection**: Call `/v3/connections/{provider}` to get an OAuth URL, or create a direct credential-based connection for Granola or Web Crawler
|
||||
2. **User Authorization**: Redirect user to complete OAuth flow when the provider requires it
|
||||
3. **Automatic Setup**: Connection established, sync begins immediately
|
||||
4. **Continuous Sync**: Real-time updates via webhooks + scheduled sync every 4 hours (or scheduled recrawling for web-crawler)
|
||||
4. **Continuous Sync**: Real-time updates via webhooks + scheduled sync every 4 hours (or scheduled recrawling for Web Crawler)
|
||||
|
||||
### Document Processing Pipeline
|
||||
|
||||
|
|
@ -228,6 +234,7 @@ graph TD
|
|||
| **Notion** | ✅ Webhooks | ✅ Every 4 hours | ✅ On-demand |
|
||||
| **OneDrive** | ✅ Webhooks (30-day expiry) | ✅ Every 4 hours | ✅ On-demand |
|
||||
| **GitHub** | ✅ Webhooks | ✅ Every 4 hours | ✅ On-demand |
|
||||
| **Granola** | ❌ Not supported | ❌ Not supported | ✅ On-demand |
|
||||
| **Web Crawler** | ❌ Not supported | ✅ Scheduled recrawling (7+ days) | ✅ On-demand |
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: "Connect Amazon S3 or S3-compatible storage to sync files into your
|
|||
icon: "aws"
|
||||
---
|
||||
|
||||
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2) to sync files into your Supermemory knowledge base.
|
||||
Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2, Tigris) to sync files into your Supermemory knowledge base.
|
||||
|
||||
<Note>
|
||||
The S3 connector requires a **Scale Plan** or higher. You can also create S3 connections directly from the [Supermemory Console](https://console.supermemory.ai).
|
||||
|
|
@ -78,7 +78,7 @@ For S3, provider-specific connection fields are passed inside the top-level `met
|
|||
| `accessKeyId` | `metadata.accessKeyId` | Yes | AWS access key ID or S3-compatible service key |
|
||||
| `secretAccessKey` | `metadata.secretAccessKey` | Yes | AWS secret access key |
|
||||
| `bucket` | `metadata.bucket` | Yes | S3 bucket name |
|
||||
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for Cloudflare R2. |
|
||||
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for S3-compatible providers that don't expose AWS-style regions (MinIO, R2, Tigris). |
|
||||
| `endpoint` | `metadata.endpoint` | No | Custom endpoint for S3-compatible services |
|
||||
| `prefix` | `metadata.prefix` | No | Key prefix filter (e.g., `documents/`) |
|
||||
| `containerTagRegex` | `metadata.containerTagRegex` | No | Regex to extract container tags from file paths |
|
||||
|
|
@ -91,7 +91,7 @@ In the Python SDK, use `container_tags` for the top-level option, but keep S3 me
|
|||
|
||||
## S3-Compatible Services
|
||||
|
||||
Use `metadata.endpoint` to connect to S3-compatible storage:
|
||||
Use `metadata.endpoint` to connect to S3-compatible storage. These services don't use AWS-style regions, so set `metadata.region` to `auto` — the value is still required for request signing but the service ignores it.
|
||||
|
||||
```typescript
|
||||
// MinIO
|
||||
|
|
@ -100,7 +100,7 @@ const connection = await client.connections.create('s3', {
|
|||
accessKeyId: 'minio-key',
|
||||
secretAccessKey: 'minio-secret',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
region: 'auto',
|
||||
endpoint: 'https://minio.example.com'
|
||||
},
|
||||
containerTags: ['minio-sync']
|
||||
|
|
@ -113,6 +113,7 @@ Common S3-compatible endpoint values:
|
|||
|---------|----------------------|-------------------|
|
||||
| DigitalOcean Spaces | `https://nyc3.digitaloceanspaces.com` | `nyc3` |
|
||||
| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` |
|
||||
| Tigris | `https://t3.storage.dev` | `auto` |
|
||||
|
||||
Cloudflare R2 example:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
---
|
||||
title: 'Self Hosting'
|
||||
description: 'Deploy your own instance of the supermemory API on Cloudflare Workers'
|
||||
---
|
||||
|
||||
<Warning>
|
||||
This guide is intended for **enterprise customers only** who have specifically opted for self-hosting as part of their enterprise plan. If you're on a standard plan, please use our hosted API at [console.supermemory.ai](https://console.supermemory.ai).
|
||||
</Warning>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, you'll need to gather several API keys and set up accounts with various services. This comprehensive guide will walk you through obtaining each required component.
|
||||
|
||||
### Enterprise Deployment Package
|
||||
|
||||
Your enterprise deployment package is provided by the supermemory team and contains:
|
||||
- Your unique Host ID (`NEXT_PUBLIC_HOST_ID`)
|
||||
- The compiled JavaScript bundle
|
||||
- The deployment script
|
||||
|
||||
Contact your supermemory enterprise representative to receive your deployment package.
|
||||
|
||||
### Cloudflare
|
||||
|
||||
|
||||
#### Create Account
|
||||
|
||||
1. Go to [cloudflare.com](https://dash.cloudflare.com/sign-up) and create an account
|
||||
3. Your **Account ID** is the long randon string in the URL bar
|
||||
|
||||
#### Create API Token
|
||||
|
||||
1. Navigate to [Cloudflare API Tokens](https://dash.cloudflare.com/?to=/:account/api-tokens)
|
||||
2. Click **"Create Token"**
|
||||
3. Use the **"Custom token"** template
|
||||
4. Configure the token with these permissions:
|
||||
- **Account:AI Gateway:Edit**
|
||||
- **Account:Hyperdrive:Edit**
|
||||
- **Account:Workers KV Storage:Edit**
|
||||
- **Account:Workers R2 Storage:Edit**
|
||||
7. Click **"Continue to summary"** → **"Create Token"**
|
||||
8. **Important**: Copy and securely store the token immediately (it won't be shown again)
|
||||
|
||||
#### Enable Workers
|
||||
|
||||
1. In your Cloudflare dashboard, go to **Workers & Pages**
|
||||
2. If prompted, accept the Workers terms of service
|
||||
3. Choose a subdomain for your workers (e.g., `yourcompany.workers.dev`)
|
||||
|
||||
Your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are now ready.
|
||||
|
||||
### Database
|
||||
|
||||
You'll need to provide a PostgreSQL connection string via the `DATABASE_URL` environment variable.
|
||||
|
||||
The database must:
|
||||
- Support the **pgvector extension** for vector operations
|
||||
- Be accessible from Cloudflare Workers
|
||||
- Support SSL connections
|
||||
- Allow connections from Cloudflare's IP ranges
|
||||
|
||||
Your connection string should follow this format:
|
||||
```
|
||||
postgresql://username:password@hostname:port/database
|
||||
```
|
||||
|
||||
### LLM Providers
|
||||
|
||||
#### OpenAI
|
||||
|
||||
1. Go to [platform.openai.com](https://platform.openai.com)
|
||||
2. Sign in or create an account
|
||||
3. Navigate to **API Keys** in the left sidebar
|
||||
4. Click **"Create new secret key"**
|
||||
5. Name your key (e.g., "supermemory Self-Hosted")
|
||||
6. Copy the key and store it securely
|
||||
7. Add billing information if you haven't already
|
||||
|
||||
#### Anthropic
|
||||
|
||||
1. Go to [console.anthropic.com](https://console.anthropic.com)
|
||||
2. Create an account and complete verification
|
||||
3. Navigate to **API Keys**
|
||||
4. Click **"Create Key"**
|
||||
5. Name your key and copy it securely
|
||||
|
||||
#### Gemini
|
||||
|
||||
1. Go to [Google AI Studio](https://aistudio.google.com)
|
||||
2. Sign in with your Google account
|
||||
3. Click **"Get API key"** → **"Create API key"**
|
||||
4. Choose an existing Google Cloud project or create a new one
|
||||
5. Copy your API key
|
||||
|
||||
#### Groq
|
||||
|
||||
1. Go to [console.groq.com](https://console.groq.com)
|
||||
2. Sign up for an account
|
||||
3. Navigate to **API Keys**
|
||||
4. Click **"Create API Key"**
|
||||
5. Name your key and copy it
|
||||
|
||||
|
||||
|
||||
{/* TODO: Add OAuth documentation */}
|
||||
{/* ### Authentication Providers
|
||||
#### GitHub OAuth (Optional)
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click **"New OAuth App"**
|
||||
3. Fill in the application details:
|
||||
- **Application name**: Your app name
|
||||
- **Homepage URL**: Your API domain (e.g., `https://api.yourdomain.com`)
|
||||
- **Authorization callback URL**: `https://api.yourdomain.com/api/auth/callback/github`
|
||||
4. Click **"Register application"**
|
||||
5. Note the **Client ID** and generate a **Client Secret**
|
||||
6. Use these for `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`
|
||||
|
||||
#### Google OAuth (Optional)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the **Google+ API**
|
||||
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
|
||||
5. Choose **Web application**
|
||||
6. Add your domain to **Authorized JavaScript origins**
|
||||
7. Add `https://api.yourdomain.com/api/auth/callback/google` to **Authorized redirect URIs**
|
||||
8. Copy the **Client ID** and **Client secret**
|
||||
9. Use these for `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` */}
|
||||
|
||||
### Email Service Setup
|
||||
|
||||
#### Resend
|
||||
|
||||
1. Go to [resend.com](https://resend.com) and create an account
|
||||
2. Navigate to **API Keys**
|
||||
3. Click **"Create API Key"**
|
||||
4. Name your key (e.g., "supermemory Production")
|
||||
5. Copy the key for `RESEND_API_KEY`
|
||||
6. Verify your sending domain in the **Domains** section
|
||||
|
||||
### Connectors (Optional)
|
||||
|
||||
#### Google Drive
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create or select a project
|
||||
3. Enable the **Google Drive API**
|
||||
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
|
||||
5. Configure the OAuth consent screen if required
|
||||
6. Choose **Web application**
|
||||
7. Add authorized redirect URIs for your domain
|
||||
8. Copy `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
#### Microsoft OneDrive
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com)
|
||||
2. Navigate to **Microsoft Entra ID** → **App registrations**
|
||||
3. Click **"New registration"**
|
||||
4. Name your app and set redirect URI
|
||||
5. Go to **Certificates & secrets** → **New client secret**
|
||||
6. Copy the **Application (client) ID** and **Client secret**
|
||||
7. Use for `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET`
|
||||
|
||||
#### Notion
|
||||
|
||||
1. Go to [Notion Developers](https://developers.notion.com)
|
||||
2. Click **"Create new integration"**
|
||||
3. Fill in the integration details
|
||||
4. Copy the **Internal Integration Token**
|
||||
5. Set up OAuth if needed for user connections
|
||||
6. Use for `NOTION_CLIENT_ID` and `NOTION_CLIENT_SECRET`
|
||||
|
||||
---
|
||||
|
||||
## Setup deployment files
|
||||
|
||||
Extract the deployment package provided by the supermemory team to your preferred directory:
|
||||
|
||||
```bash
|
||||
# Extract the deployment package
|
||||
$ unzip supermemory-enterprise-deployment.zip
|
||||
$ cd supermemory-deployment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configure environment variables
|
||||
|
||||
The deployment script reads **all** environment variables from your shell at runtime. We ship an example file that lists the full set supported by the worker.
|
||||
|
||||
```bash
|
||||
# Copy the template and start editing
|
||||
$ cp packages/alchemy/env.example .env
|
||||
|
||||
# Open the file in your editor of choice and fill in the blanks
|
||||
$ $EDITOR .env
|
||||
```
|
||||
|
||||
Below is a quick reference.
|
||||
**Required** values are mandatory for a successful deploy – leave optional ones empty if you don't need the related feature.
|
||||
|
||||
| Name | Required? | Description |
|
||||
|------|-----------|-------------|
|
||||
| `NODE_ENV` | ✅ | `development`, `staging` or `production`. |
|
||||
| `NEXT_PUBLIC_HOST_ID` | ✅ | Your unique Host ID provided by the supermemory team. |
|
||||
| `BETTER_AUTH_SECRET` | ✅ | Random 32-byte string – run `openssl rand -base64 32`. |
|
||||
| `BETTER_AUTH_URL` | ✅ | Public base URL for the API (no trailing `/`). Example: `https://api.example.com`. |
|
||||
| `DATABASE_URL` | ✅ | Postgres connection string (e.g. `postgres://user:pass@host:5432/db`). |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Your Cloudflare account ID. |
|
||||
| `CLOUDFLARE_API_TOKEN` | ✅ | Token created in *Prerequisites*. |
|
||||
| `OPENAI_API_KEY` | ✅ | Key from [platform.openai.com](https://platform.openai.com). |
|
||||
| `RESEND_API_KEY` | ✅ | E-mail provider key if you plan to send e-mails. |
|
||||
| `ANTHROPIC_API_KEY` | | Needed to use Claude models. |
|
||||
| `GEMINI_API_KEY` | | Key for Google Gemini models. |
|
||||
| `GROQ_API_KEY` | | Key for Groq models. |
|
||||
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | | Enable GitHub OAuth login. |
|
||||
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | | Enable Google OAuth login. |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | | Needed for Google Drive connector. |
|
||||
| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | | Needed for OneDrive connector. |
|
||||
| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | | Needed for Notion connector. |
|
||||
| `CLOUDFLARE_AI_GATEWAY_NAME` / `CLOUDFLARE_AI_GATEWAY_TOKEN` | | Only if you want to route requests through an AI Gateway. |
|
||||
| `SENTRY_DSN` | | If you use Sentry for error reporting. |
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
With your `.env` in place, run the deployment script:
|
||||
|
||||
```bash
|
||||
# Run the deployment script provided in your package
|
||||
$ bun ./deploy.ts
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Updating Your Deployment
|
||||
|
||||
To update your supermemory deployment, follow the same process as the initial deployment described in the **Deploy** section above. You can reuse your existing `.env` file and add/remove any new environment variables as needed.
|
||||
|
||||
---
|
||||
|
|
@ -71,6 +71,16 @@
|
|||
"group": "Getting Started",
|
||||
"pages": ["intro", "quickstart", "vibe-coding"]
|
||||
},
|
||||
{
|
||||
"group": "Self-Hosting",
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
"self-hosting/quickstart",
|
||||
"self-hosting/configuration",
|
||||
"self-hosting/embeddings",
|
||||
"self-hosting/local-vs-enterprise"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Concepts",
|
||||
"pages": [
|
||||
|
|
@ -79,6 +89,7 @@
|
|||
"concepts/content-types",
|
||||
"concepts/super-rag",
|
||||
"concepts/memory-vs-rag",
|
||||
"concepts/container-tags",
|
||||
"concepts/filtering",
|
||||
"concepts/user-profiles",
|
||||
"concepts/customization",
|
||||
|
|
@ -94,7 +105,11 @@
|
|||
{
|
||||
"group": "Manage Content",
|
||||
"icon": "folder-cog",
|
||||
"pages": ["document-operations", "memory-operations"]
|
||||
"pages": [
|
||||
"document-operations",
|
||||
"memory-operations",
|
||||
"memory-review"
|
||||
]
|
||||
},
|
||||
"overview/use-cases"
|
||||
]
|
||||
|
|
@ -112,6 +127,7 @@
|
|||
"connectors/gmail",
|
||||
"connectors/onedrive",
|
||||
"connectors/s3",
|
||||
"connectors/granola",
|
||||
"connectors/github",
|
||||
"connectors/web-crawler"
|
||||
]
|
||||
|
|
@ -295,7 +311,7 @@
|
|||
"anchors": [
|
||||
{
|
||||
"anchor": "Changelog",
|
||||
"pages": ["changelog/overview"]
|
||||
"pages": ["changelog/overview", "changelog/plugins"]
|
||||
}
|
||||
],
|
||||
"tab": "Changelog"
|
||||
|
|
|
|||
1
apps/docs/images/granola.svg
Normal file
1
apps/docs/images/granola.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
|
|
@ -140,6 +140,18 @@ const model = withSupermemory(openai("gpt-5"), {
|
|||
})
|
||||
```
|
||||
|
||||
### Persisting Tool Calls (default: off)
|
||||
|
||||
By default, saved conversations include only user and assistant text — tool calls and tool results are dropped, since tool payloads are often large and low-signal and would pollute memory extraction. To persist the full tool round trip (tool calls with their arguments, plus tool results, in their original order), set `includeToolCalls: true`:
|
||||
|
||||
```typescript
|
||||
const model = withSupermemory(openai("gpt-5"), {
|
||||
containerTag: "user-123",
|
||||
customId: "conv-1",
|
||||
includeToolCalls: true,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Tools
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ icon: "/images/claude-code-icon.svg"
|
|||
This integration requires the **Supermemory Pro plan**. [Upgrade here](https://console.supermemory.ai/billing).
|
||||
</Warning>
|
||||
|
||||
[Claude-Supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
[supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/
|
|||
/plugin marketplace add supermemoryai/claude-supermemory
|
||||
|
||||
# Install the plugin
|
||||
/plugin install claude-supermemory
|
||||
/plugin install supermemory
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
|
@ -72,12 +76,12 @@ Once installed, the plugin runs automatically:
|
|||
|
||||
## Commands
|
||||
|
||||
### /claude-supermemory:logout
|
||||
### /supermemory:logout
|
||||
|
||||
Log out from Supermemory and clear saved credentials.
|
||||
|
||||
```
|
||||
/claude-supermemory:logout
|
||||
/supermemory:logout
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ icon: "terminal"
|
|||
- **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session.
|
||||
- **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` (or set `baseUrl` in `~/.codex/supermemory.json`) and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile:
|
||||
|
|
@ -45,7 +49,7 @@ This command:
|
|||
- Copies hook and skill scripts to `~/.codex/supermemory/`
|
||||
- Enables `codex_hooks = true` in `~/.codex/config.toml`
|
||||
- Registers `UserPromptSubmit` (recall) and `Stop` (capture) hooks in `~/.codex/hooks.json`
|
||||
- Installs `supermemory-search`, `supermemory-save`, and `supermemory-forget` skills to `~/.codex/skills/`
|
||||
- Installs `supermemory-search`, `supermemory-save`, `supermemory-forget`, and `supermemory-status` skills to `~/.codex/skills/`
|
||||
|
||||
Restart Codex CLI after installing.
|
||||
|
||||
|
|
@ -77,13 +81,14 @@ Tags are generated automatically — no configuration needed. You can override t
|
|||
|
||||
## Explicit Memory Skills
|
||||
|
||||
The installer includes three skills that Codex auto-discovers from `~/.codex/skills/`. They use the same `SUPERMEMORY_CODEX_API_KEY` as the hooks — no separate login needed.
|
||||
The installer includes four skills that Codex auto-discovers from `~/.codex/skills/`. They use the same `SUPERMEMORY_CODEX_API_KEY` as the hooks — no separate login needed.
|
||||
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `supermemory-search` | Search your memories by natural-language query |
|
||||
| `supermemory-save` | Save important project knowledge to memory |
|
||||
| `supermemory-forget` | Remove outdated or incorrect memories |
|
||||
| `supermemory-status` | Check Supermemory connection, hook, config, and skill status |
|
||||
|
||||
These skills let you interact with memory explicitly — for example:
|
||||
|
||||
|
|
@ -91,6 +96,7 @@ These skills let you interact with memory explicitly — for example:
|
|||
> Remember that this project uses Vitest for unit tests and Playwright for E2E.
|
||||
> What do you remember about our database schema?
|
||||
> Forget the memory about the old API endpoint.
|
||||
> Is Supermemory connected?
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
|
@ -107,7 +113,7 @@ codex-supermemory status:
|
|||
API key: ✓ set (SUPERMEMORY_CODEX_API_KEY)
|
||||
Hook scripts: ✓ installed at ~/.codex/supermemory
|
||||
hooks.json: ✓ registered (implicit memory)
|
||||
Skills: ✓ installed (supermemory-search, supermemory-save, supermemory-forget)
|
||||
Skills: ✓ installed (supermemory-search, supermemory-save, supermemory-forget, supermemory-status)
|
||||
config.toml: ✓ exists
|
||||
|
||||
All good! Memory is active.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
|
|||
|
||||
[OpenClaw](https://github.com/supermemoryai/openclaw-supermemory) is a multi-platform AI messaging gateway that connects to WhatsApp, Telegram, Discord, Slack, iMessage, and other messaging channels. The Supermemory plugin gives OpenClaw memory across every channel.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_BASE_URL="http://localhost:6767"` (or set `baseUrl` in the plugin config) and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Install the Plugin
|
||||
|
||||
Get started by installing the plugin with a single command.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
|
|||
|
||||
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ icon: "/images/supermemory.svg"
|
|||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Tip>
|
||||
Both SDKs also work against [self-hosted Supermemory](/self-hosting/overview) — pass `baseURL: "http://localhost:6767"` (TypeScript) or `base_url="http://localhost:6767"` (Python) when creating the client.
|
||||
</Tip>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
## Installation
|
||||
|
|
|
|||
|
|
@ -82,5 +82,8 @@ All three approaches share the **same context pool** when using the same user ID
|
|||
<Card title="How it Works" icon="cpu" href="/concepts/how-it-works">
|
||||
Understand the knowledge graph architecture
|
||||
</Card>
|
||||
<Card title="Self-host it" icon="server" href="/self-hosting/overview">
|
||||
Run Supermemory on your own machine — one binary, zero config, fully offline
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -121,28 +121,163 @@ Use **Create Memories** when you already know the exact facts to store (user pre
|
|||
|
||||
## Forget Memory
|
||||
|
||||
Soft-delete a memory — excluded from search results but preserved in the system. Use this when you might want to restore later.
|
||||
Soft-delete a single memory — excluded from search results but preserved in the database. Identify it by `id` or by exact `content`, scoped to its `containerTag`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
await fetch("https://api.supermemory.ai/v4/memories/mem_abc123/forget", {
|
||||
method: "POST",
|
||||
await fetch("https://api.supermemory.ai/v4/memories", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`
|
||||
}
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// Identify by ID or by exact content
|
||||
id: "mem_abc123",
|
||||
// content: "John prefers dark mode",
|
||||
containerTag: "user_123",
|
||||
reason: "outdated information"
|
||||
})
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v4/memories/mem_abc123/forget" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY"
|
||||
curl -X DELETE "https://api.supermemory.ai/v4/memories" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"id": "mem_abc123",
|
||||
"containerTag": "user_123",
|
||||
"reason": "outdated information"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The memory will no longer appear in search results but remains in the database.
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | * | Memory ID to forget |
|
||||
| `content` | string | * | Exact content match to forget (alternative to ID) |
|
||||
| `containerTag` | string | yes | Container tag / space the memory belongs to |
|
||||
| `reason` | string | no | Optional reason recorded as `forgetReason` |
|
||||
|
||||
\* Either `id` or `content` must be provided.
|
||||
|
||||
The memory will no longer appear in search results but remains in the database (`isForgotten=true`).
|
||||
|
||||
---
|
||||
|
||||
## Forget Matching
|
||||
|
||||
Forget **everything** about a topic in one call. You give a prompt or a query; the service semantically searches the container's memories, an LLM decides which ones are genuinely about your target, and those are soft-deleted. Use this for "forget everything about X" rather than deleting memories one by one.
|
||||
|
||||
<Warning>
|
||||
This is a bulk, destructive operation. Always **`dryRun` first** to review what would be forgotten, then re-run with `dryRun: false`. The match is semantic, so a too-broad query can select more than you intend — `threshold` and `maxForget` bound the blast radius.
|
||||
</Warning>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
// 1) Preview
|
||||
const preview = await fetch("https://api.supermemory.ai/v4/memories/forget-matching", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: "forget everything about Project Titan",
|
||||
containerTag: "user_123",
|
||||
dryRun: true
|
||||
})
|
||||
}).then((r) => r.json());
|
||||
// preview.candidates → [{ id, memory, score }, ...]
|
||||
|
||||
// 2) Apply
|
||||
const result = await fetch("https://api.supermemory.ai/v4/memories/forget-matching", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: "forget everything about Project Titan",
|
||||
containerTag: "user_123",
|
||||
dryRun: false,
|
||||
reason: "project cancelled"
|
||||
})
|
||||
}).then((r) => r.json());
|
||||
// result.forgotten → [{ id, memory, score }, ...]
|
||||
// result.forgetBatchId → tagged on every forgotten memory for traceability
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
# Preview
|
||||
curl -X POST "https://api.supermemory.ai/v4/memories/forget-matching" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "forget everything about Project Titan",
|
||||
"containerTag": "user_123",
|
||||
"dryRun": true
|
||||
}'
|
||||
|
||||
# Apply
|
||||
curl -X POST "https://api.supermemory.ai/v4/memories/forget-matching" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "forget everything about Project Titan",
|
||||
"containerTag": "user_123",
|
||||
"dryRun": false,
|
||||
"reason": "project cancelled"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `query` | string | yes | What to forget — a natural-language instruction ("forget everything about Project Titan") or a bare topic ("Project Titan") |
|
||||
| `containerTag` | string | yes | Container tag / space to scope the operation to |
|
||||
| `dryRun` | boolean | no | When `true`, returns what *would* be forgotten without changing anything. Defaults to `false` |
|
||||
| `threshold` | number | no | Similarity floor (0–1) for candidate memories. Lower casts a wider net. Defaults to `0.5` |
|
||||
| `maxForget` | number | no | Safety cap on how many memories may be forgotten in one call (1–500). Defaults to `100` |
|
||||
| `reason` | string | no | Reason recorded as `forgetReason` on each forgotten memory |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"dryRun": false,
|
||||
"count": 3,
|
||||
"forgetBatchId": "VcuQoGRz4hA4ak5Xu6DRUN",
|
||||
"summary": "Forgot 3 memories about \"Project Titan\".",
|
||||
"forgotten": [
|
||||
{ "id": "mem_1", "memory": "Project Titan ships in Q3", "score": 0.82 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `dryRun` | boolean | Whether this was a preview or a real forget |
|
||||
| `count` | number | Number of memories selected (dryRun) or forgotten (apply) |
|
||||
| `forgetBatchId` | string \| null | ID tagged on every memory forgotten in this call; `null` on dryRun |
|
||||
| `summary` | string | One-line summary of the operation (e.g. `Forgot 3 memories about "Project Titan".`) |
|
||||
| `candidates` | array | On `dryRun`: the memories that **would** be forgotten (`{ id, memory, score }`) |
|
||||
| `forgotten` | array | On apply: the memories that **were** forgotten (`{ id, memory, score }`) |
|
||||
|
||||
<Note>
|
||||
Identity is server-owned: the LLM only ever references opaque handles for the memories a search returned, so it can never forget a memory outside the results it reviewed, and every operation is scoped to the `containerTag` you pass.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -201,6 +336,7 @@ Update a memory by creating a new version. The original is preserved with `isLat
|
|||
|
||||
## Next Steps
|
||||
|
||||
- [Review Inferred Memories](/memory-review) — Approve or decline low-confidence memories
|
||||
- [Document Operations](/document-operations) — Manage documents (SDK supported)
|
||||
- [Search](/search) — Query your memories
|
||||
- [Ingesting Content](/add-memories) — Add new content
|
||||
|
|
|
|||
288
apps/docs/memory-review.mdx
Normal file
288
apps/docs/memory-review.mdx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
---
|
||||
title: "Review Inferred Memories"
|
||||
sidebarTitle: "Memory Review"
|
||||
description: "List and act on low-confidence inferred memories — approve, decline, or undo"
|
||||
icon: "list-checks"
|
||||
---
|
||||
|
||||
Supermemory's graph automatically **derives** new facts from patterns across your
|
||||
existing memories (see [Graph Memory](/concepts/graph-memory)). These derived facts
|
||||
are guesses — the engine wasn't told them directly — so they are flagged as
|
||||
**inferred** (`isInference: true`) and **down-weighted in search** until confirmed.
|
||||
|
||||
These two endpoints let you build a review experience on top of that queue: list the
|
||||
inferred memories awaiting review, then **approve**, **decline**, or **undo** a
|
||||
decision on each one.
|
||||
|
||||
<Info>
|
||||
These endpoints are scoped to a single [container tag](/concepts/container-tags)
|
||||
(space), under `/v3/container-tags/{containerTag}`.
|
||||
</Info>
|
||||
|
||||
## How review affects ranking
|
||||
|
||||
While a memory is unreviewed and inferred it is down-weighted in search, so the
|
||||
engine's guesses rank below facts you stated explicitly. Reviewing it resolves that
|
||||
either way:
|
||||
|
||||
| Action | Result | Effect on search |
|
||||
|--------|--------|------------------|
|
||||
| **Approve** | `isInference` cleared | Ranks like a stated fact — no longer down-weighted |
|
||||
| **Decline** | `isForgotten` set | Removed from search entirely — a rejected guess is forgotten |
|
||||
| **Undo** | back to unreviewed | Returns to the queue; inferred and down-weighted again |
|
||||
|
||||
A reviewed memory is stamped with `reviewStatus` in its metadata so it drops out of the
|
||||
review queue (declined memories also leave search, since they're forgotten). **Undo**
|
||||
clears that stamp — and un-forgets a declined memory — bringing it back.
|
||||
|
||||
---
|
||||
|
||||
## List Inferred Memories
|
||||
|
||||
Return the inferred memories for a container tag that are still awaiting review (the
|
||||
review queue). Reviewed memories are excluded.
|
||||
|
||||
```
|
||||
GET /v3/container-tags/{containerTag}/inferred
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
const res = await fetch(
|
||||
"https://api.supermemory.ai/v3/container-tags/user_123/inferred",
|
||||
{ headers: { "Authorization": `Bearer ${API_KEY}` } }
|
||||
);
|
||||
|
||||
const { memories, total } = await res.json();
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl "https://api.supermemory.ai/v3/container-tags/user_123/inferred" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Path parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `containerTag` | string | The container tag / space to read the review queue for |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"id": "mem_abc123",
|
||||
"memory": "Alex likely works on Stripe's core payments product",
|
||||
"parentCount": 3,
|
||||
"createdAt": "2025-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2025-01-15T10:30:00.000Z",
|
||||
"metadata": { "source": "derive" }
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `memories[].id` | string | Memory entry ID — pass to the review endpoint |
|
||||
| `memories[].memory` | string | The inferred memory text |
|
||||
| `memories[].parentCount` | number | How many source memories this was derived from. Higher = stronger signal |
|
||||
| `memories[].createdAt` | string | ISO 8601 timestamp |
|
||||
| `memories[].updatedAt` | string | ISO 8601 timestamp |
|
||||
| `memories[].metadata` | object \| null | Arbitrary metadata stored on the memory |
|
||||
| `total` | number | Count of unreviewed inferred memories returned |
|
||||
|
||||
<Note>
|
||||
The queue returns up to **50** memories, ordered by `parentCount` descending (most
|
||||
strongly supported first), then by `createdAt` descending. It excludes anything that
|
||||
is forgotten, expired, or already reviewed. An unknown or empty container tag returns
|
||||
`{ "memories": [], "total": 0 }`.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Review an Inferred Memory
|
||||
|
||||
Record a decision on a single inferred memory.
|
||||
|
||||
```
|
||||
POST /v3/container-tags/{containerTag}/inferred/{memoryId}/review
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
const res = await fetch(
|
||||
"https://api.supermemory.ai/v3/container-tags/user_123/inferred/mem_abc123/review",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ action: "approve" })
|
||||
}
|
||||
);
|
||||
|
||||
const result = await res.json();
|
||||
// { id: "mem_abc123", isInference: false, isForgotten: false, reviewStatus: "approved" }
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST \
|
||||
"https://api.supermemory.ai/v3/container-tags/user_123/inferred/mem_abc123/review" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"action": "approve"}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Path parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `containerTag` | string | The container tag / space the memory belongs to |
|
||||
| `memoryId` | string | The memory entry ID from the list endpoint |
|
||||
|
||||
### Body parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `action` | string | yes | One of `approve`, `decline`, or `undo` |
|
||||
|
||||
<Warning>
|
||||
The reject action is named **`decline`**. There is no `reject` value.
|
||||
</Warning>
|
||||
|
||||
**Action semantics:**
|
||||
|
||||
- **`approve`** — Promote the memory: clears `isInference`, so it ranks like a stated
|
||||
fact instead of a down-weighted guess. Stamps `reviewStatus: "approved"`.
|
||||
- **`decline`** — Reject the suggestion: the memory is **forgotten**
|
||||
(`isForgotten: true`) and stamped `reviewStatus: "declined"`, so it leaves both
|
||||
search and the review queue.
|
||||
- **`undo`** — Revert a prior `approve`/`decline` back to the unreviewed inferred
|
||||
state: restores `isInference: true`, un-forgets the memory (`isForgotten: false`),
|
||||
and clears the review stamp, so it returns to the queue.
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mem_abc123",
|
||||
"isInference": false,
|
||||
"isForgotten": false,
|
||||
"reviewStatus": "approved"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string | The reviewed memory ID |
|
||||
| `isInference` | boolean | `false` after approve; `true` after decline or undo |
|
||||
| `isForgotten` | boolean | `true` after decline (the memory is forgotten); `false` otherwise |
|
||||
| `reviewStatus` | `"approved"` \| `"declined"` \| `null` | The new status; `null` after an undo |
|
||||
|
||||
### Errors
|
||||
|
||||
| Status | When |
|
||||
|--------|------|
|
||||
| `401` | Missing or invalid authentication |
|
||||
| `404` | The container tag or memory was not found in your organization |
|
||||
| `409` | The memory isn't reviewable for this action — it's not an inferred memory, or there's no prior review to undo |
|
||||
|
||||
---
|
||||
|
||||
## Building a review experience
|
||||
|
||||
The endpoints are designed for an optimistic, one-at-a-time review UI (swipe to keep /
|
||||
decline, with undo). A typical client fetches the queue once, then pops each card off
|
||||
locally as the user decides — `undo` re-adds it.
|
||||
|
||||
<Accordion title="React Query hooks (TypeScript)">
|
||||
```typescript
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const BASE = "https://api.supermemory.ai/v3";
|
||||
const key = (tag: string) => ["inferred-memories", tag] as const;
|
||||
|
||||
export type InferredMemory = {
|
||||
id: string;
|
||||
memory: string;
|
||||
parentCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ReviewAction = "approve" | "decline" | "undo";
|
||||
|
||||
export function useInferredMemories(containerTag: string) {
|
||||
return useQuery({
|
||||
queryKey: key(containerTag),
|
||||
queryFn: async (): Promise<InferredMemory[]> => {
|
||||
const res = await fetch(`${BASE}/container-tags/${containerTag}/inferred`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to load review queue");
|
||||
const data = await res.json();
|
||||
return data.memories ?? [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewInferredMemory(containerTag: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (vars: { memoryId: string; action: ReviewAction }) => {
|
||||
const res = await fetch(
|
||||
`${BASE}/container-tags/${containerTag}/inferred/${vars.memoryId}/review`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: vars.action }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Review failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (_data, { memoryId, action }) => {
|
||||
// approve/decline remove the card; undo brings it back, so refetch.
|
||||
if (action === "undo") {
|
||||
queryClient.invalidateQueries({ queryKey: key(containerTag) });
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<InferredMemory[]>(key(containerTag), (prev) =>
|
||||
prev?.filter((m) => m.id !== memoryId),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Note>
|
||||
There is no separate "skip" action. A swipe-to-skip is purely client-side — don't send
|
||||
a request and the memory simply stays in the queue for a later session.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Graph Memory](/concepts/graph-memory) — How inferred (`derive`) memories are created
|
||||
- [Memory Operations](/memory-operations) — Create, forget, and update memories
|
||||
- [Search](/search) — How inferred memories are ranked in results
|
||||
|
|
@ -8,6 +8,10 @@ icon: "play"
|
|||
**Using Vercel AI SDK?** Check out the [AI SDK integration](/integrations/ai-sdk) for the cleanest implementation with `@supermemory/tools/ai-sdk`.
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
**Prefer to run it locally?** Supermemory is also a [self-hostable single binary](/self-hosting/overview) — `curl -fsSL https://supermemory.ai/install | bash` and you're running.
|
||||
</Tip>
|
||||
|
||||
## Memory API
|
||||
|
||||
**Step 1.** Sign up for [Supermemory's Developer Platform](http://console.supermemory.ai) to get the API key. Click on **API Keys -> Create API Key** to generate one.
|
||||
|
|
|
|||
153
apps/docs/self-hosting/configuration.mdx
Normal file
153
apps/docs/self-hosting/configuration.mdx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
---
|
||||
title: "Self-Hosting Configuration"
|
||||
sidebarTitle: "Configuration"
|
||||
description: "Every environment variable the self-hosted server understands."
|
||||
icon: "settings"
|
||||
---
|
||||
|
||||
The self-hosted server aims for **zero configuration** — the only required input is one LLM provider key, which the first-boot wizard collects interactively (or set via env var for non-interactive deployments). Embeddings default to local English; you can pick another provider in the optional wizard step or via env. Everything else below is opt-in.
|
||||
|
||||
The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager.
|
||||
|
||||
## Core
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PORT` (or `SUPERMEMORY_PORT`) | HTTP listen port | `6767` |
|
||||
| `SUPERMEMORY_DATA_DIR` | Where the graph engine's data, auth secret, and model cache live | `./.supermemory` |
|
||||
|
||||
## LLM providers
|
||||
|
||||
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own LLM for the intelligent steps — summaries, contextual chunking, and memory extraction. Embeddings default to a local model (no API key) and can optionally use OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). Configure **at least one** LLM provider:
|
||||
|
||||
| Variable | Provider |
|
||||
|---|---|
|
||||
| `OPENAI_API_KEY` | OpenAI — or any OpenAI-compatible endpoint, see below |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic |
|
||||
| `GEMINI_API_KEY` | Google AI Studio (Gemini) |
|
||||
| `GROQ_API_KEY` | Groq |
|
||||
| `WORKERS_AI_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Workers AI |
|
||||
| `GOOGLE_VERTEX_PROJECT_ID` + `GOOGLE_VERTEX_LOCATION` | GCP Vertex AI |
|
||||
|
||||
<Tip>
|
||||
No key set? The server walks you through it. On first boot, an interactive setup wizard asks which provider you want, securely prompts for the key, and saves it encrypted — including a custom base URL and model name if you pick an OpenAI-compatible endpoint.
|
||||
</Tip>
|
||||
|
||||
With multiple providers configured, the first one in the order above is used.
|
||||
|
||||
<Note>
|
||||
Image, video, and high-fidelity PDF understanding require a Gemini or Vertex AI key. Text ingestion, memory extraction, and search work with any provider.
|
||||
</Note>
|
||||
|
||||
### Fully offline with local models
|
||||
|
||||
`OPENAI_API_KEY` + `OPENAI_BASE_URL` covers any OpenAI-compatible endpoint: Ollama, LM Studio, vLLM, llama.cpp server, Together, Fireworks, and more.
|
||||
|
||||
```bash
|
||||
# Ollama example — gpt-oss-20b works great
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
OPENAI_API_KEY=ollama # any non-empty string for local runners
|
||||
OPENAI_MODEL=gpt-oss:20b
|
||||
```
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `OPENAI_BASE_URL` | OpenAI-compatible endpoint URL | OpenAI |
|
||||
| `OPENAI_MODEL` | Model ID sent to that endpoint | `gpt-5.1` |
|
||||
| `OPENAI_FAST_MODEL` | Override for fast/light tasks | `OPENAI_MODEL` |
|
||||
| `OPENAI_TEXT_MODEL` | Override for heavier text tasks | `OPENAI_MODEL` |
|
||||
|
||||
## File storage
|
||||
|
||||
Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
|
||||
|
||||
## Embeddings
|
||||
|
||||
By default, vectors are computed locally with `Xenova/bge-base-en-v1.5` (768d) — no embedding API key. On interactive first boot you can pick a different provider after the LLM key step; for Docker/CI set env vars instead.
|
||||
|
||||
Full provider table, multilingual guidance, remote examples (OpenAI / Gemini / Ollama), and the re-ingestion / dimension-lock warning: **[Embeddings (self-hosted)](/self-hosting/embeddings)**.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local`, `openai`, `gemini`, or OpenAI-compatible remote | `local` |
|
||||
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` |
|
||||
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match model and stored data | `768` |
|
||||
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs | unset |
|
||||
|
||||
### Embedding performance
|
||||
|
||||
Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
|
||||
| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
|
||||
|
||||
## Memory limits & ingestion queue
|
||||
|
||||
The server manages memory for you and separates the two kinds of work you send it:
|
||||
|
||||
- **Searches are always served immediately.** They never wait behind ingestion, regardless of how much is queued.
|
||||
- **Adds are accepted instantly but processed through a queue.** A `POST /v3/documents` call returns in milliseconds with status `queued`; extraction, embedding, and indexing happen in the background at a controlled pace.
|
||||
|
||||
Ingestion may grow the server's memory usage by at most `SUPERMEMORY_EMBEDDING_RAM_LIMIT` (default **1 GB**) above its post-boot baseline. Past that, new documents simply wait in the queue until memory drops back under the limit — nothing is dropped, ingestion just slows down. The limit is measured above the boot baseline because the built-in local embeddings and storage engine have a fixed footprint that exists before any document is processed.
|
||||
|
||||
The limit is printed at boot, and whenever adds are waiting the binary shows a live status line in the terminal:
|
||||
|
||||
```
|
||||
[ingest] memory limit 1.0 GB above baseline (1.6 GB) · 2 concurrent — set SUPERMEMORY_EMBEDDING_RAM_LIMIT=ngb to change
|
||||
[ingest] 2 running · 193 queued · 0.4 GB / 1.0 GB ingest memory
|
||||
[ingest] 2 running · 193 queued · paused — 1.1 GB / 1.0 GB ingest memory, waiting for it to drop
|
||||
[ingest] resumed — memory back under the 1.0 GB ingest limit
|
||||
```
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_RAM_LIMIT` | Memory ingestion may use above the boot baseline. Accepts `1gb`, `1.5gb`, `512mb`, or a bare number (GB). | `1gb` |
|
||||
| `SUPERMEMORY_INGEST_CONCURRENCY` | Documents processed concurrently | `2` |
|
||||
|
||||
```bash
|
||||
# Give ingestion 4 GB of headroom on a larger machine
|
||||
SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server
|
||||
```
|
||||
|
||||
Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly.
|
||||
|
||||
## Telemetry
|
||||
|
||||
The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_DISABLE_TELEMETRY` | Set to `1` to also disable internal AI SDK telemetry instrumentation | unset |
|
||||
|
||||
## Platform-only features
|
||||
|
||||
These exist in the codebase but are exclusive to the [hosted platform](https://console.supermemory.ai) — the self-hosted binary doesn't include them:
|
||||
|
||||
- **Connectors** — Google Drive, Notion, Gmail, OneDrive background sync
|
||||
- **Supermemory MCP** — managed MCP server endpoints
|
||||
- **Optimized memory extraction** — the platform's extraction pipeline is tuned for higher quality at lower cost than bring-your-own-key
|
||||
- **Managed scale** — globally distributed infrastructure, no capacity planning
|
||||
|
||||
Any other environment variables you may find referenced in the codebase are platform-only: the self-hosted binary ignores them even when set.
|
||||
|
||||
## Example: production-ish `.env`
|
||||
|
||||
```dotenv
|
||||
# Persistent data location
|
||||
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
|
||||
|
||||
# One LLM provider (required for extraction)
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Optional — omit to keep local Xenova/bge-base-en-v1.5 (768d)
|
||||
# SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
# SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
|
||||
```
|
||||
|
||||
That's enough for full ingestion, memory extraction, and hybrid search with the default local embeddings.
|
||||
135
apps/docs/self-hosting/embeddings.mdx
Normal file
135
apps/docs/self-hosting/embeddings.mdx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
title: "Embeddings (self-hosted)"
|
||||
sidebarTitle: "Embeddings"
|
||||
description: "Local and remote embedding providers for Supermemory local — defaults, env vars, multilingual options, and dimension lock."
|
||||
icon: "waypoints"
|
||||
---
|
||||
|
||||
Self-hosted Supermemory uses the **same embedding provider stack** as the hosted platform: local ONNX models, OpenAI, Gemini, or any OpenAI-compatible embeddings endpoint (including Ollama). LLM keys power extraction and summarization; embeddings are configured separately.
|
||||
|
||||
## Defaults
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Provider | `local` |
|
||||
| Model | `Xenova/bge-base-en-v1.5` |
|
||||
| Dimensions | `768` |
|
||||
| API key | None — runs on your machine |
|
||||
|
||||
Press Enter at the optional first-boot picker to keep this default. Nothing is sent off-box to embed.
|
||||
|
||||
<Warning>
|
||||
The default local model is **English-only**. Non-English content can ingest successfully while dense semantic recall stays weak. See [Multilingual](#multilingual).
|
||||
</Warning>
|
||||
|
||||
## First-time setup (interactive)
|
||||
|
||||
On first boot with a TTY, Supermemory asks for an LLM API key (required), then optionally which embedding model to use.
|
||||
|
||||
1. Choose or paste an LLM provider key (OpenAI, Anthropic, Gemini, Groq, or OpenAI-compatible).
|
||||
2. Optionally pick an embedding provider/model. **Press Enter to keep the local English model.**
|
||||
3. Choices are saved encrypted under your data directory (`$SUPERMEMORY_DATA_DIR`, typically `./.supermemory` / `~/.supermemory`).
|
||||
|
||||
Boot order is intentional: LLM keys load first so remote embedding options can reuse them (for example OpenAI or Gemini embeddings with the same key).
|
||||
|
||||
<Tip>
|
||||
**First boot (terminal):** Supermemory asks for an LLM API key (required), then optionally which embedding model to use. Press Enter to keep the local English model. Choices are saved encrypted under your data directory.
|
||||
</Tip>
|
||||
|
||||
## Configuration (env)
|
||||
|
||||
For Docker, CI, or any non-interactive deploy, set env vars — there is **no interactive prompt without a TTY**.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_PROVIDER` | Embedding backend: `local`, `openai`, `gemini`, or an OpenAI-compatible remote (`ollama` / custom base URL) | `local` |
|
||||
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` (local) |
|
||||
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match the model and any already-stored data | `768` (local default) |
|
||||
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs (Ollama, vLLM, etc.) | unset |
|
||||
| `OPENAI_API_KEY` | Used when provider is `openai` (or compatible) if not otherwise supplied | unset |
|
||||
| `GEMINI_API_KEY` | Used when provider is `gemini` | unset |
|
||||
|
||||
Local worker tuning (throughput only — does not change model or dimensions):
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
|
||||
| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
|
||||
|
||||
Ingestion memory headroom is controlled by `SUPERMEMORY_EMBEDDING_RAM_LIMIT` — see [Memory limits & ingestion queue](/self-hosting/configuration#memory-limits--ingestion-queue).
|
||||
|
||||
<Tip>
|
||||
**Docker / production:** Set at least one LLM key and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `SUPERMEMORY_EMBEDDING_MODEL` / `SUPERMEMORY_EMBEDDING_DIMENSIONS` (and base URL or API key as needed). There is no interactive prompt without a TTY.
|
||||
</Tip>
|
||||
|
||||
## Multilingual
|
||||
|
||||
The default `Xenova/bge-base-en-v1.5` model is trained for English. For German, Dutch, and other non-English corpora, dense recall can fail even when hybrid keyword search still finds rare tokens.
|
||||
|
||||
For multilingual or non-English deployments, switch **before** large backfills:
|
||||
|
||||
```bash
|
||||
# Example: local multilingual (set dimensions to match the model)
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=local
|
||||
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-m3
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=1024
|
||||
```
|
||||
|
||||
Or use a remote multilingual embedding API (OpenAI, Gemini, or Ollama with a multilingual embed model). Set provider, model, and dimensions together. Changing them later requires a fresh data directory or full re-ingestion — see below.
|
||||
|
||||
## Remote providers
|
||||
|
||||
### Local (default)
|
||||
|
||||
```bash
|
||||
# Explicit local default — no embedding API key
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=local
|
||||
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
|
||||
```
|
||||
|
||||
### Gemini
|
||||
|
||||
```bash
|
||||
GEMINI_API_KEY=...
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=gemini
|
||||
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-004
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
### Ollama (OpenAI-compatible)
|
||||
|
||||
```bash
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
OPENAI_API_KEY=ollama
|
||||
SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
Use the dimension published for your chosen model. A mismatch with vectors already in the store fails boot.
|
||||
|
||||
## Changing models later
|
||||
|
||||
<Warning>
|
||||
**Not supported in place.** Embeddings from different models (or different dimensions) are not comparable. Start from a fresh data directory or re-ingest all content so vectors stay in one space. If configured dimensions disagree with stored data, the server **refuses to boot**.
|
||||
</Warning>
|
||||
|
||||
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
|
||||
- [Quickstart](/self-hosting/quickstart) — install and first memory
|
||||
56
apps/docs/self-hosting/local-vs-enterprise.mdx
Normal file
56
apps/docs/self-hosting/local-vs-enterprise.mdx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: "Local vs. Enterprise"
|
||||
sidebarTitle: "Local vs. Enterprise"
|
||||
description: "Supermemory local is for builders. Supermemory Enterprise is for organizations."
|
||||
icon: "building-2"
|
||||
---
|
||||
|
||||
Supermemory local — the self-hosted binary — is free, open source, and built for individual developers: local-first workflows, prototyping, air-gapped experiments, privacy-sensitive side projects.
|
||||
|
||||
**Supermemory Enterprise** is the full platform, run for your organization: the same memory engine with proprietary models, organizational controls, and infrastructure that scales with you — without you operating any of it.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Supermemory local | Enterprise |
|
||||
|---|---|---|
|
||||
| **Memory engine** | Full graph engine, embedded | Full graph engine, managed |
|
||||
| **Models** | Bring your own key (any provider, incl. fully offline) | Proprietary models tuned for long-horizon data understanding |
|
||||
| **Authentication** | Single auto-generated API key | Organization-wide authentication and access controls |
|
||||
| **Team access** | Single org on one machine | Multi-member organizations, roles, and scoped API keys |
|
||||
| **Observability** | Server logs | Control dashboard: usage analytics, ingestion monitoring, request logs |
|
||||
| **Control** | Env vars on your box | Org-wide settings, key management, and governance from the console |
|
||||
| **Connectors** | — | Google Drive, Notion, Gmail, OneDrive with continuous background sync |
|
||||
| **Scalability** | One machine, one process | Globally distributed, scales elastically with your workload |
|
||||
| **Hosting** | You run it | Fully managed — or dedicated deployments for compliance needs |
|
||||
| **Support** | Community ([GitHub](https://git.new/memory)) | Dedicated support, onboarding, and SLAs |
|
||||
|
||||
## What Enterprise adds
|
||||
|
||||
### Auth and team access
|
||||
|
||||
Local runs as a single-tenant server with one API key. Enterprise gives your whole organization structured access: member roles, and API keys scoped per environment, per team, or per app — all revocable from one place.
|
||||
|
||||
### Observability and the control dashboard
|
||||
|
||||
Local gives you logs. Enterprise gives you the console: live usage analytics, ingestion pipeline visibility, search and request logs, and per-key attribution — so you always know what your agents are remembering, and what it costs.
|
||||
|
||||
### Memory quality
|
||||
|
||||
Local runs the extraction pipeline on whatever model you bring. Enterprise runs it on Supermemory's proprietary models, purpose-tuned for long-horizon data understanding — higher-quality memories at a lower effective cost than any bring-your-own-key setup.
|
||||
|
||||
### Scale and hosting
|
||||
|
||||
Local is bounded by one machine — which is the point. Enterprise runs on globally distributed infrastructure that scales with your ingestion volume and query load, with no capacity planning on your side. For strict data residency or compliance requirements, dedicated deployment options are available.
|
||||
|
||||
## Moving between them
|
||||
|
||||
The two speak the same API. Code written against your local server moves to Enterprise by changing the `baseURL` — and vice versa. Prototype locally, ship on Enterprise.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Talk to us" icon="mail" href="mailto:dhravya@supermemory.com">
|
||||
Get a walkthrough of Supermemory Enterprise for your team
|
||||
</Card>
|
||||
<Card title="Start locally" icon="terminal" href="/self-hosting/quickstart">
|
||||
Install the binary and build against the same API today
|
||||
</Card>
|
||||
</CardGroup>
|
||||
88
apps/docs/self-hosting/overview.mdx
Normal file
88
apps/docs/self-hosting/overview.mdx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
title: "Self-Hosting Supermemory"
|
||||
sidebarTitle: "Overview"
|
||||
description: "State-of-the-art memory, running on your machine. One binary, zero config."
|
||||
icon: "server"
|
||||
---
|
||||
|
||||
Supermemory runs on your own hardware. It's the same memory engine behind the [hosted platform](https://console.supermemory.ai) — ingestion, memory extraction, hybrid semantic search, and the full API — as a single self-contained binary.
|
||||
|
||||
<CodeGroup>
|
||||
```bash curl
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
|
||||
```bash npx
|
||||
npx supermemory local
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
No Docker. No database to provision. No config files. It boots in seconds with everything built in, and it's [open source](https://git.new/memory).
|
||||
|
||||
## Zero config, actually
|
||||
|
||||
Run the binary with nothing set and you get a complete memory system:
|
||||
|
||||
- **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings.
|
||||
- **Built-in local embeddings** — default `Xenova/bge-base-en-v1.5` (768d) on your machine, no API key. Same provider stack as cloud if you opt into OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings).
|
||||
- **An API key, generated for you** — printed on first boot, ready to paste into any SDK.
|
||||
- **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works.
|
||||
|
||||
The only thing you bring is a model. In production, Supermemory runs its own proprietary models, purpose-tuned for long-horizon data understanding and memory extraction. Self-hosted, the same pipeline runs on whatever model you point it at — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. Bring a key and go. Or don't bring one at all:
|
||||
|
||||
## Runs fully offline
|
||||
|
||||
Supermemory works with any OpenAI-compatible endpoint, which means it runs end-to-end on your machine with a local model — Ollama, LM Studio, vLLM, llama.cpp. `gpt-oss-20b` is a great fit:
|
||||
|
||||
```bash
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
||||
OPENAI_API_KEY=ollama \
|
||||
OPENAI_MODEL=gpt-oss:20b \
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
Local graph engine, local embeddings, local LLM. Your data never leaves the building.
|
||||
|
||||
## Drop-in with your existing code
|
||||
|
||||
The self-hosted server speaks the same API as the hosted platform. Point any Supermemory SDK at it with a one-line change:
|
||||
|
||||
```typescript
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...", // printed on first boot
|
||||
baseURL: "http://localhost:6767",
|
||||
})
|
||||
```
|
||||
|
||||
Everything in the [Memory API docs](/quickstart) works the same way. The coding plugins do too — [Claude Code](/integrations/claude-code), [Codex](/integrations/codex), and [OpenCode](/integrations/opencode) all target your local server with `SUPERMEMORY_API_URL=http://localhost:6767`.
|
||||
|
||||
## Self-hosted vs. the platform
|
||||
|
||||
Self-hosted is free, open source, and great for local development, air-gapped environments, and privacy-sensitive workloads. The hosted platform is where the full product lives:
|
||||
|
||||
| | Self-hosted | Platform |
|
||||
|---|---|---|
|
||||
| Full Memory API | ✅ | ✅ |
|
||||
| Hybrid semantic search | ✅ | ✅ |
|
||||
| Embeddings | Local default (or OpenAI / Gemini / Ollama) | Same provider stack, managed |
|
||||
| File ingestion (PDFs, images) | ✅ | ✅ |
|
||||
| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
|
||||
| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
|
||||
| Memory extraction | Your model, your key | Proprietary long-horizon models — higher quality, cheaper at scale |
|
||||
| Infrastructure | Your machine | Globally distributed, scales with you |
|
||||
|
||||
If you outgrow a single machine — or want connectors, MCP, and the best-tuned extraction pipeline — [the platform](https://console.supermemory.ai) is one `baseURL` change away. Running this for a team or organization? See [Local vs. Enterprise](/self-hosting/local-vs-enterprise).
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Quickstart" icon="play" href="/self-hosting/quickstart">
|
||||
Install, run, and store your first memory in under two minutes
|
||||
</Card>
|
||||
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
|
||||
Every environment variable: LLM providers, storage, auth, tuning
|
||||
</Card>
|
||||
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
|
||||
Local default, remote providers, multilingual, dimension lock
|
||||
</Card>
|
||||
</CardGroup>
|
||||
158
apps/docs/self-hosting/quickstart.mdx
Normal file
158
apps/docs/self-hosting/quickstart.mdx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
title: "Self-Hosting Quickstart"
|
||||
sidebarTitle: "Quickstart"
|
||||
description: "From zero to your first memory in under two minutes."
|
||||
icon: "play"
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
<Tabs>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="npx">
|
||||
```bash
|
||||
npx supermemory local
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="bunx">
|
||||
```bash
|
||||
bunx supermemory local
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
First boot sets everything up — the embedded Supermemory graph engine, local embeddings, and your credentials:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ url http://localhost:6767 │
|
||||
│ database ./.supermemory │
|
||||
│ api key sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
|
||||
│ org id xxxxxxxxxxxxxxxxxxxxxx │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Save that API key — it's your bearer token for every request.
|
||||
|
||||
<Note>
|
||||
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. After the LLM key, you can optionally pick an embedding model (press Enter to keep local `Xenova/bge-base-en-v1.5`). See [all providers](/self-hosting/configuration#llm-providers), [embeddings](/self-hosting/embeddings), and [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
**Docker / non-interactive:** set an LLM key via env and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `MODEL` / `DIMENSIONS`. There is no wizard without a TTY.
|
||||
</Tip>
|
||||
|
||||
## Add your first memory
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
import Supermemory from "supermemory"
|
||||
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...",
|
||||
baseURL: "http://localhost:6767",
|
||||
})
|
||||
|
||||
await client.memories.add({
|
||||
content: "I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
|
||||
containerTag: "user_dhravya",
|
||||
})
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory(
|
||||
api_key="sm_...",
|
||||
base_url="http://localhost:6767",
|
||||
)
|
||||
|
||||
client.memories.add(
|
||||
content="I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
|
||||
container_tag="user_dhravya",
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl http://localhost:6767/v3/documents \
|
||||
-H "Authorization: Bearer sm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"content": "I am Dhravya. I love building dev tools and I am allergic to peanuts.",
|
||||
"containerTag": "user_dhravya"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Search it
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
const results = await client.search.memories({
|
||||
q: "what food should I avoid?",
|
||||
containerTag: "user_dhravya",
|
||||
})
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
results = client.search.memories(
|
||||
q="what food should I avoid?",
|
||||
container_tag="user_dhravya",
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl http://localhost:6767/v3/search \
|
||||
-H "Authorization: Bearer sm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"q": "what food should I avoid?",
|
||||
"containerTag": "user_dhravya"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
That's it. Everything in the [Memory API](/quickstart) — documents, memories, user profiles, spaces, filtering — works identically against your local server.
|
||||
|
||||
## Where things live
|
||||
|
||||
By default, all state lives in a single directory you can back up or move:
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `./.supermemory/` (or `$SUPERMEMORY_DATA_DIR`) | The Supermemory graph engine's data, auth secret, embedding model cache |
|
||||
| `~/.supermemory/env` | API keys saved by the installer, loaded on every launch |
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
|
||||
LLM providers, local models, performance tuning
|
||||
</Card>
|
||||
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
|
||||
Local default, OpenAI / Gemini / Ollama, multilingual
|
||||
</Card>
|
||||
<Card title="Memory API" icon="book-open" href="/quickstart">
|
||||
The full API — it all works against your local server
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -121,6 +121,9 @@ Get profile and search results in one call by adding the `q` parameter:
|
|||
| `containerTag` | string | Yes | User/project identifier |
|
||||
| `q` | string | No | Search query (includes search results in response) |
|
||||
| `threshold` | 0-1 | No | Filter search results by relevance score |
|
||||
| `filters` | object | No | Metadata filters applied to profile and search results |
|
||||
| `include` | string[] | No | Sections to return — any of `"static"`, `"dynamic"`, `"buckets"`. Omit to return all |
|
||||
| `buckets` | string[] | No | Restrict the `buckets` section to specific keys. Omit for all configured buckets |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -180,6 +183,142 @@ ${result.searchResults?.results.map(m => m.memory).join('\n') || 'None'}
|
|||
|
||||
---
|
||||
|
||||
## Profile Buckets
|
||||
|
||||
Buckets are **custom topical categories** for a profile — an axis that sits alongside
|
||||
`static` and `dynamic`. Where static/dynamic split facts by how long-lived they are,
|
||||
buckets group them by subject (e.g. `preferences`, `goals`, `work`). As content is
|
||||
ingested, a classifier assigns each memory to the buckets it matches, so you can pull
|
||||
just the slice of context a given surface needs.
|
||||
|
||||
Every org starts with a built-in `preferences` bucket. You can define your own at the
|
||||
organization or space level in your console settings; space-level buckets are
|
||||
**add-only** — a container tag inherits all org buckets and may add more, but cannot
|
||||
disable them.
|
||||
|
||||
### Requesting buckets
|
||||
|
||||
Pass `include: ["buckets"]` to return bucket-organized memories, and optionally
|
||||
`buckets` to limit the response to specific keys. `include` also lets you skip
|
||||
sections you don't need — `["buckets"]` alone omits `static` and `dynamic`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
const res = await fetch("https://api.supermemory.ai/v4/profile", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
containerTag: "user_123",
|
||||
include: ["buckets"],
|
||||
buckets: ["preferences", "goals"] // optional — omit for all buckets
|
||||
})
|
||||
});
|
||||
|
||||
const { profile } = await res.json();
|
||||
console.log(profile.buckets.preferences);
|
||||
console.log(profile.buckets.goals);
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v4/profile" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"containerTag": "user_123",
|
||||
"include": ["buckets"],
|
||||
"buckets": ["preferences", "goals"]
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
"buckets": {
|
||||
"preferences": [
|
||||
"[Summary] Prefers concise, technical answers and dark-mode tooling",
|
||||
"[Recent] Switched their editor to Zed"
|
||||
],
|
||||
"goals": [
|
||||
"[Recent] Wants to ship the billing revamp this quarter"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
**`[Recent]` and `[Summary]` labels.** To keep profiles dense, an entity's older
|
||||
memories are periodically aggregated into a short synthesis. Entries prefixed
|
||||
`[Summary]` are that aggregated context; entries prefixed `[Recent]` were ingested
|
||||
since the last aggregation and aren't summarized yet. The `dynamic` section uses the
|
||||
same `[Recent]` prefix (plus a `[YYYY-MM-DD]` date). Strip the prefixes if you only
|
||||
want raw text, or keep them to signal recency to your model.
|
||||
</Note>
|
||||
|
||||
### List bucket definitions
|
||||
|
||||
To see which buckets are configured for a container tag (org buckets merged with any
|
||||
space-level additions), call `/v4/profile/buckets`:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="fetch">
|
||||
```typescript
|
||||
const res = await fetch("https://api.supermemory.ai/v4/profile/buckets", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ containerTag: "user_123" })
|
||||
});
|
||||
|
||||
const { buckets } = await res.json();
|
||||
// [{ key: "preferences", description: "..." }, ...]
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v4/profile/buckets" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"containerTag": "user_123"}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"buckets": [
|
||||
{
|
||||
"key": "preferences",
|
||||
"description": "Explicit first-person preferences the person directly stated."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `buckets[].key` | string | Stable slug, also stored on each memory. Lowercase alphanumeric with `-`/`_`, 1–64 chars |
|
||||
| `buckets[].description` | string | What belongs in the bucket — guides the ingestion classifier |
|
||||
|
||||
<Tip>
|
||||
Bucket descriptions steer classification. A precise description ("Explicit
|
||||
first-person preferences only — exclude inferred traits") yields cleaner buckets than
|
||||
a vague one. `static` and `dynamic` are reserved and can't be used as bucket keys.
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Framework Examples
|
||||
|
||||
<Accordion title="Express.js Middleware">
|
||||
|
|
@ -249,8 +388,9 @@ ${result.searchResults?.results.map(m => m.memory).join('\n') || 'None'}
|
|||
```typescript
|
||||
interface ProfileResponse {
|
||||
profile: {
|
||||
static: string[]; // Long-term facts
|
||||
dynamic: string[]; // Recent context
|
||||
static?: string[]; // Long-term facts
|
||||
dynamic?: string[]; // Recent context
|
||||
buckets?: Record<string, string[]>; // Topical buckets, keyed by bucket key
|
||||
};
|
||||
searchResults?: { // Only if q parameter provided
|
||||
results: SearchResult[];
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Replace `claude` with: `cursor`, `opencode`, or `vscode`
|
|||
After adding the MCP, paste this in your agent session:
|
||||
|
||||
<Accordion title="Copy prompt below." icon='copy'>
|
||||
```
|
||||
````
|
||||
You are integrating Supermemory into my application. Supermemory provides user memory, semantic search, and automatic knowledge extraction for AI applications.
|
||||
|
||||
Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
|
||||
|
|
@ -386,7 +386,7 @@ NOW:
|
|||
3. Include installation, settings config, and full integration
|
||||
|
||||
DOCS: https://supermemory.ai/docs
|
||||
```
|
||||
````
|
||||
</Accordion>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,24 @@ Search memories and get user profile.
|
|||
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
|
||||
| `containerTag` | string | No | Project tag to scope the search |
|
||||
|
||||
### `listMemories`
|
||||
|
||||
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"containerTag": "optional-project-tag"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `page` | integer | No | Page number (1-based). Default: `1` |
|
||||
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
|
||||
| `containerTag` | string | No | Project tag to scope the listing |
|
||||
|
||||
### `whoAmI`
|
||||
|
||||
Get the current logged-in user's information.
|
||||
|
|
@ -170,6 +188,55 @@ The server will start at `http://localhost:8788`.
|
|||
|
||||
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
|
||||
core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` →
|
||||
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
|
||||
the `context` prompt, container-tag isolation, and auth rejections.
|
||||
|
||||
```bash
|
||||
export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it)
|
||||
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
|
||||
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
|
||||
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
|
||||
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
|
||||
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
|
||||
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
|
||||
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
|
||||
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
|
||||
|
||||
#### OAuth flow tests
|
||||
|
||||
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
|
||||
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
|
||||
|
||||
- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize
|
||||
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
|
||||
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
|
||||
`/mcp` with it, exercising the OAuth-token validation path (not the `sm_` API-key path). It
|
||||
**skips** unless both env vars below are set.
|
||||
|
||||
```bash
|
||||
# One-time capture (opens a browser for login + consent, prints the env vars):
|
||||
bun e2e/capture-oauth-token.ts
|
||||
export SUPERMEMORY_MCP_CLIENT_ID=...
|
||||
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the
|
||||
refresh-token env vars — so CI is safe without secrets.
|
||||
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
|
||||
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
|
||||
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
|
|
|
|||
65
apps/mcp/e2e/auth.test.ts
Normal file
65
apps/mcp/e2e/auth.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { MCP_URL, ORIGIN } from "./helpers"
|
||||
|
||||
const initBody = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "smtest", version: "0.0.1" },
|
||||
},
|
||||
})
|
||||
|
||||
const mcpHeaders = (auth?: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
...(auth ? { Authorization: auth } : {}),
|
||||
})
|
||||
|
||||
// No API key needed — exercises the public surface and auth rejections.
|
||||
describe("MCP — transport & auth (raw HTTP)", () => {
|
||||
it("GET / returns service info", async () => {
|
||||
const res = await fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { name?: string; version?: string }
|
||||
expect(body.name).toBe("supermemory-mcp")
|
||||
expect(body.version).toBeTruthy()
|
||||
})
|
||||
|
||||
it("exposes OAuth protected-resource discovery", async () => {
|
||||
const res = await fetch(
|
||||
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
resource?: string
|
||||
authorization_servers?: string[]
|
||||
}
|
||||
expect(body.resource).toMatch(/\/mcp$/)
|
||||
expect(Array.isArray(body.authorization_servers)).toBe(true)
|
||||
expect(body.authorization_servers?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("rejects a request with no token (401 + WWW-Authenticate)", async () => {
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders(),
|
||||
body: initBody,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
|
||||
})
|
||||
|
||||
it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
|
||||
body: initBody,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
expect(body.error?.message).toMatch(/invalid|expired/i)
|
||||
})
|
||||
})
|
||||
103
apps/mcp/e2e/capture-oauth-token.ts
Normal file
103
apps/mcp/e2e/capture-oauth-token.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { exec } from "node:child_process"
|
||||
|
||||
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
|
||||
const PORT = 8765
|
||||
const REDIRECT_URI = `http://localhost:${PORT}/callback`
|
||||
|
||||
const b64url = (b: Buffer) =>
|
||||
b
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
|
||||
async function main() {
|
||||
const meta = (await (
|
||||
await fetch(`${API_URL}/.well-known/oauth-authorization-server`)
|
||||
).json()) as {
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
registration_endpoint: string
|
||||
}
|
||||
|
||||
const reg = (await (
|
||||
await fetch(meta.registration_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "sm-mcp-e2e-capture",
|
||||
redirect_uris: [REDIRECT_URI],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
})
|
||||
).json()) as { client_id: string }
|
||||
|
||||
const verifier = b64url(randomBytes(32))
|
||||
const challenge = b64url(createHash("sha256").update(verifier).digest())
|
||||
const state = b64url(randomBytes(16))
|
||||
|
||||
const authUrl = new URL(meta.authorization_endpoint)
|
||||
authUrl.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: reg.client_id,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
state,
|
||||
}).toString()
|
||||
|
||||
const code: string = await new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const u = new URL(req.url ?? "", `http://localhost:${PORT}`)
|
||||
if (u.pathname !== "/callback") return res.end()
|
||||
if (u.searchParams.get("state") !== state) {
|
||||
res.end("state mismatch")
|
||||
return reject(new Error("state mismatch"))
|
||||
}
|
||||
const c = u.searchParams.get("code")
|
||||
res.end("Done — you can close this tab.")
|
||||
server.close()
|
||||
c ? resolve(c) : reject(new Error("no code in callback"))
|
||||
}).listen(PORT, () => {
|
||||
console.log(`\nOpening browser to log in:\n${authUrl}\n`)
|
||||
exec(`open "${authUrl}" || xdg-open "${authUrl}"`)
|
||||
})
|
||||
})
|
||||
|
||||
const tokenRes = (await (
|
||||
await fetch(meta.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: reg.client_id,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
})
|
||||
).json()) as { refresh_token?: string; error?: string }
|
||||
|
||||
if (!tokenRes.refresh_token) {
|
||||
console.error("No refresh_token returned:", tokenRes)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\nExport these to enable Tier D OAuth tests:\n")
|
||||
console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`)
|
||||
console.log(
|
||||
`export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
52
apps/mcp/e2e/discovery.test.ts
Normal file
52
apps/mcp/e2e/discovery.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"memory",
|
||||
"recall",
|
||||
"listProjects",
|
||||
"whoAmI",
|
||||
"memory-graph",
|
||||
]
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("handshakes and lists the expected tools", async () => {
|
||||
const { tools } = await s.client.listTools()
|
||||
const names = tools.map((t) => t.name)
|
||||
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
|
||||
})
|
||||
|
||||
it("lists profile & projects resources", async () => {
|
||||
const { resources } = await s.client.listResources()
|
||||
const uris = resources.map((r) => r.uri)
|
||||
expect(uris).toContain("supermemory://profile")
|
||||
expect(uris).toContain("supermemory://projects")
|
||||
})
|
||||
|
||||
it("lists the context prompt", async () => {
|
||||
const { prompts } = await s.client.listPrompts()
|
||||
expect(prompts.map((p) => p.name)).toContain("context")
|
||||
})
|
||||
|
||||
it("whoAmI resolves to the authenticated account", async () => {
|
||||
const res = await callTool(s.client, "whoAmI")
|
||||
expect(res.isError).toBeFalsy()
|
||||
const parsed = JSON.parse(textOf(res))
|
||||
expect(parsed.userId).toBeTruthy()
|
||||
})
|
||||
|
||||
it("listProjects returns content", async () => {
|
||||
const res = await callTool(s.client, "listProjects", { refresh: true })
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
59
apps/mcp/e2e/graph.test.ts
Normal file
59
apps/mcp/e2e/graph.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("memory-graph returns a summary + structured documents", async () => {
|
||||
const res = await callTool(s.client, "memory-graph")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
|
||||
const sc = res.structuredContent as {
|
||||
documents?: unknown[]
|
||||
totalCount?: number
|
||||
}
|
||||
expect(Array.isArray(sc?.documents)).toBe(true)
|
||||
})
|
||||
|
||||
it("fetch-graph-data returns paginated documents", async () => {
|
||||
const res = await callTool(s.client, "fetch-graph-data", {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
const sc = res.structuredContent as {
|
||||
documents?: unknown[]
|
||||
pagination?: { limit?: number }
|
||||
}
|
||||
expect(Array.isArray(sc?.documents)).toBe(true)
|
||||
expect(sc?.pagination?.limit).toBe(5)
|
||||
})
|
||||
|
||||
it("reads the profile resource", async () => {
|
||||
const res = await s.client.readResource({ uri: "supermemory://profile" })
|
||||
expect(res.contents.length).toBeGreaterThan(0)
|
||||
expect(res.contents[0].mimeType).toBe("text/plain")
|
||||
expect(typeof res.contents[0].text).toBe("string")
|
||||
})
|
||||
|
||||
it("reads the projects resource as JSON", async () => {
|
||||
const res = await s.client.readResource({ uri: "supermemory://projects" })
|
||||
const text = res.contents[0].text as string
|
||||
const parsed = JSON.parse(text)
|
||||
expect(Array.isArray(parsed.projects)).toBe(true)
|
||||
})
|
||||
|
||||
it("gets the context prompt as a system message", async () => {
|
||||
const res = await s.client.getPrompt({ name: "context", arguments: {} })
|
||||
expect(res.messages.length).toBeGreaterThan(0)
|
||||
const text = res.messages[0].content.text as string
|
||||
expect(text).toMatch(/memory|context/i)
|
||||
})
|
||||
})
|
||||
186
apps/mcp/e2e/helpers.ts
Normal file
186
apps/mcp/e2e/helpers.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
|
||||
export const MCP_URL =
|
||||
process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
|
||||
export const API_KEY = process.env.SUPERMEMORY_API_KEY
|
||||
export const ORIGIN = new URL(MCP_URL).origin
|
||||
export const API_URL =
|
||||
process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts.
|
||||
export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN
|
||||
export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID
|
||||
|
||||
export type AuthServerMetadata = {
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
registration_endpoint: string
|
||||
grant_types_supported?: string[]
|
||||
code_challenge_methods_supported?: string[]
|
||||
response_types_supported?: string[]
|
||||
}
|
||||
|
||||
// Walk the discovery chain a real MCP client follows: protected-resource → authorization server.
|
||||
export async function authServerMetadata(): Promise<{
|
||||
authServer: string
|
||||
metadata: AuthServerMetadata
|
||||
}> {
|
||||
const prRes = await fetch(
|
||||
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
|
||||
)
|
||||
const pr = (await prRes.json()) as { authorization_servers?: string[] }
|
||||
const authServer = pr.authorization_servers?.[0]
|
||||
if (!authServer) throw new Error("no authorization_servers in metadata")
|
||||
const metaRes = await fetch(
|
||||
`${authServer}/.well-known/oauth-authorization-server`,
|
||||
)
|
||||
return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata }
|
||||
}
|
||||
|
||||
export async function registerClient(registrationEndpoint: string): Promise<{
|
||||
status: number
|
||||
body: { client_id?: string; grant_types?: string[] }
|
||||
}> {
|
||||
const res = await fetch(registrationEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "sm-mcp-e2e",
|
||||
redirect_uris: ["http://localhost:8765/callback"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
})
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
|
||||
export async function exchangeRefreshToken(
|
||||
tokenEndpoint: string,
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
): Promise<{
|
||||
status: number
|
||||
body: { access_token?: string; error?: string }
|
||||
}> {
|
||||
const res = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
}),
|
||||
})
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
|
||||
export type CallResult = {
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
structuredContent?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function textOf(res: CallResult): string {
|
||||
return (res.content ?? [])
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export type Session = { client: Client; close: () => Promise<void> }
|
||||
|
||||
export async function connect(
|
||||
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
|
||||
): Promise<Session> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
|
||||
}
|
||||
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
requestInit: { headers },
|
||||
})
|
||||
const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" })
|
||||
await client.connect(transport)
|
||||
return {
|
||||
client,
|
||||
close: () => transport.close().catch(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function callTool(
|
||||
client: Client,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<CallResult> {
|
||||
return (await client.callTool({ name, arguments: args })) as CallResult
|
||||
}
|
||||
|
||||
// recall is eventually-consistent (save → ingestion pipeline → memories), so poll.
|
||||
export async function recallUntil(
|
||||
client: Client,
|
||||
query: string,
|
||||
needle: string,
|
||||
{
|
||||
tries = 18,
|
||||
delayMs = 5000,
|
||||
containerTag = undefined as string | undefined,
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "recall", {
|
||||
query,
|
||||
includeProfile: false,
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
})
|
||||
const txt = textOf(res)
|
||||
if (txt.includes(needle)) return txt
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// forget only matches extracted memory entries, not raw chunks, so a just-saved doc
|
||||
// returns "No matching memory found..." until extraction completes — poll for real removal.
|
||||
export async function forgetUntilForgotten(
|
||||
client: Client,
|
||||
content: string,
|
||||
{
|
||||
tries = 18,
|
||||
delayMs = 5000,
|
||||
containerTag = undefined as string | undefined,
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
})
|
||||
if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// poll until a memory is NO LONGER returned (for verifying forget).
|
||||
export async function recallUntilAbsent(
|
||||
client: Client,
|
||||
query: string,
|
||||
needle: string,
|
||||
{ tries = 12, delayMs = 5000 } = {},
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "recall", {
|
||||
query,
|
||||
includeProfile: false,
|
||||
})
|
||||
if (!textOf(res).includes(needle)) return true
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
82
apps/mcp/e2e/list-memories.test.ts
Normal file
82
apps/mcp/e2e/list-memories.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
sleep,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
// listMemories reads extracted memory entries, which appear only after the
|
||||
// async ingestion pipeline finishes — poll like recallUntil does.
|
||||
async function listUntil(
|
||||
s: Session,
|
||||
needle: string,
|
||||
{ tries = 18, delayMs = 5000 } = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
// The marker document is the newest, so page 1 is enough.
|
||||
const res = await callTool(s.client, "listMemories", { limit: 20 })
|
||||
const txt = textOf(res)
|
||||
if (txt.includes(needle)) return txt
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
|
||||
let s: Session
|
||||
const created: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
for (const content of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
}
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("appears in tool discovery", async () => {
|
||||
const tools = await s.client.listTools()
|
||||
const names = tools.tools.map((t) => t.name)
|
||||
expect(names).toContain("listMemories")
|
||||
})
|
||||
|
||||
it("lists a saved memory without dumping document content", async () => {
|
||||
const marker = `lm-${randomUUID()}`
|
||||
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
|
||||
created.push(content)
|
||||
|
||||
const save = await callTool(s.client, "memory", { content, action: "save" })
|
||||
expect(save.isError).toBeFalsy()
|
||||
|
||||
const listing = await listUntil(s, marker)
|
||||
expect(
|
||||
listing,
|
||||
`listMemories never returned marker ${marker}`,
|
||||
).not.toBeNull()
|
||||
// Header shape: "N memories across M documents (page X of Y, ...)"
|
||||
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
|
||||
}, 120_000)
|
||||
|
||||
it("paginates with a bounded page size", async () => {
|
||||
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
|
||||
expect(res.isError).toBeFalsy()
|
||||
const txt = textOf(res)
|
||||
// With the memory saved above there is at least one document.
|
||||
expect(txt).toMatch(/page 1 of \d+/)
|
||||
}, 30_000)
|
||||
|
||||
it("rejects an out-of-range limit", async () => {
|
||||
const res = await callTool(s.client, "listMemories", { limit: 500 })
|
||||
// Zod schema caps limit at 50 — the SDK surfaces this as a tool error.
|
||||
expect(res.isError).toBeTruthy()
|
||||
}, 30_000)
|
||||
})
|
||||
127
apps/mcp/e2e/memory.test.ts
Normal file
127
apps/mcp/e2e/memory.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
callTool,
|
||||
connect,
|
||||
forgetUntilForgotten,
|
||||
recallUntil,
|
||||
recallUntilAbsent,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
||||
let s: Session
|
||||
const created: Array<{ content: string; containerTag?: string }> = []
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
for (const { content, containerTag } of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("save → recall round-trips the saved memory", async () => {
|
||||
const marker = `rt-${randomUUID()}`
|
||||
const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.`
|
||||
created.push({ content })
|
||||
|
||||
const save = await callTool(s.client, "memory", { content, action: "save" })
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toMatch(/Saved memory/i)
|
||||
|
||||
const found = await recallUntil(s.client, "test fruit dragonfruit", marker)
|
||||
expect(found, `recall never returned marker ${marker}`).not.toBeNull()
|
||||
}, 120_000)
|
||||
|
||||
it("recall includeProfile=true returns profile + memories sections", async () => {
|
||||
const res = await callTool(s.client, "recall", {
|
||||
query: "dragonfruit",
|
||||
includeProfile: true,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
const txt = textOf(res)
|
||||
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
|
||||
}, 30_000)
|
||||
|
||||
// Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty.
|
||||
it("recall responds gracefully for an unmatched query", async () => {
|
||||
const res = await callTool(s.client, "recall", {
|
||||
query: `zzz-no-such-memory-${randomUUID()}`,
|
||||
includeProfile: false,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
|
||||
})
|
||||
|
||||
// Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort.
|
||||
it("forget accepts and removes a saved memory", async () => {
|
||||
const marker = `fg-${randomUUID()}`
|
||||
const content = `e2e forget target. token=${marker}. Secret animal is axolotl.`
|
||||
created.push({ content })
|
||||
|
||||
await callTool(s.client, "memory", { content, action: "save" })
|
||||
const found = await recallUntil(s.client, "secret animal axolotl", marker)
|
||||
expect(found, "memory should exist before forget").not.toBeNull()
|
||||
|
||||
// Polls forget until it confirms real removal ("forgot"), past the extraction window.
|
||||
const forgotten = await forgetUntilForgotten(s.client, content)
|
||||
expect(
|
||||
forgotten,
|
||||
`forget never confirmed removal for ${marker} (memory entry never extracted in time)`,
|
||||
).not.toBeNull()
|
||||
|
||||
const gone = await recallUntilAbsent(
|
||||
s.client,
|
||||
"secret animal axolotl",
|
||||
marker,
|
||||
)
|
||||
if (!gone) {
|
||||
console.warn(
|
||||
`[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`,
|
||||
)
|
||||
}
|
||||
}, 240_000)
|
||||
|
||||
it("containerTag scopes memories (isolation)", async () => {
|
||||
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
|
||||
const tagA = "sm_e2e_scope_a"
|
||||
const tagB = "sm_e2e_scope_b"
|
||||
const marker = `sc-${randomUUID()}`
|
||||
const content = `e2e scoping. token=${marker}. Project color is teal.`
|
||||
created.push({ content, containerTag: tagA })
|
||||
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "save",
|
||||
containerTag: tagA,
|
||||
})
|
||||
|
||||
const inA = await recallUntil(s.client, "project color teal", marker, {
|
||||
containerTag: tagA,
|
||||
})
|
||||
expect(inA, "marker should be found in its own container").not.toBeNull()
|
||||
|
||||
// Same query scoped to a different container must NOT see it.
|
||||
const leaked = await recallUntil(s.client, "project color teal", marker, {
|
||||
containerTag: tagB,
|
||||
tries: 3,
|
||||
delayMs: 3000,
|
||||
})
|
||||
expect(leaked, "marker leaked across containers").toBeNull()
|
||||
}, 120_000)
|
||||
|
||||
it("returns an error result for a missing required argument", async () => {
|
||||
const res = await callTool(s.client, "recall", {})
|
||||
expect(res.isError).toBe(true)
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
126
apps/mcp/e2e/oauth.test.ts
Normal file
126
apps/mcp/e2e/oauth.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
authServerMetadata,
|
||||
type AuthServerMetadata,
|
||||
callTool,
|
||||
connect,
|
||||
exchangeRefreshToken,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_REFRESH_TOKEN,
|
||||
registerClient,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
|
||||
describe("MCP — OAuth protocol (no secrets)", () => {
|
||||
let meta: AuthServerMetadata
|
||||
|
||||
beforeAll(async () => {
|
||||
meta = (await authServerMetadata()).metadata
|
||||
})
|
||||
|
||||
// Tier A — the discovery chain a client walks from a 401 to the auth server.
|
||||
it("discovers the authorization server from protected-resource metadata", () => {
|
||||
expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
|
||||
expect(meta.token_endpoint).toMatch(/\/token$/)
|
||||
expect(meta.registration_endpoint).toMatch(/\/register$/)
|
||||
})
|
||||
|
||||
it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
|
||||
expect(meta.code_challenge_methods_supported).toContain("S256")
|
||||
expect(meta.response_types_supported).toContain("code")
|
||||
expect(meta.grant_types_supported).toContain("authorization_code")
|
||||
expect(meta.grant_types_supported).toContain("refresh_token")
|
||||
})
|
||||
|
||||
// Tier B — Dynamic Client Registration, the first authenticated-flow step.
|
||||
it("issues a client_id via dynamic client registration", async () => {
|
||||
const { status, body } = await registerClient(meta.registration_endpoint)
|
||||
expect(status).toBe(201)
|
||||
expect(body.client_id).toBeTruthy()
|
||||
expect(body.grant_types).toContain("refresh_token")
|
||||
})
|
||||
|
||||
// Tier C — token endpoint rejects forged grants with proper OAuth errors.
|
||||
it("rejects a bogus refresh_token with invalid_grant", async () => {
|
||||
const { status, body } = await exchangeRefreshToken(
|
||||
meta.token_endpoint,
|
||||
"bogus_rt_for_e2e",
|
||||
"bogus_client",
|
||||
)
|
||||
expect(status).toBe(401)
|
||||
expect(body.error).toBe("invalid_grant")
|
||||
})
|
||||
|
||||
it("rejects a bogus authorization code with invalid_grant", async () => {
|
||||
const res = await fetch(meta.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: "bogus_code",
|
||||
client_id: "bogus",
|
||||
code_verifier: "abc",
|
||||
redirect_uri: "http://localhost:8765/callback",
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(((await res.json()) as { error?: string }).error).toBe(
|
||||
"invalid_grant",
|
||||
)
|
||||
})
|
||||
|
||||
it("redirects an unauthenticated authorize request to login", async () => {
|
||||
const url = new URL(meta.authorization_endpoint)
|
||||
url.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: "any",
|
||||
redirect_uri: "http://localhost:8765/callback",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
state: "xyz",
|
||||
}).toString()
|
||||
const res = await fetch(url, { redirect: "manual" })
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers.get("location")).toMatch(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
|
||||
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
|
||||
"MCP — real OAuth token round-trip",
|
||||
() => {
|
||||
let s: Session
|
||||
let accessToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const { metadata } = await authServerMetadata()
|
||||
const { status, body } = await exchangeRefreshToken(
|
||||
metadata.token_endpoint,
|
||||
OAUTH_REFRESH_TOKEN as string,
|
||||
OAUTH_CLIENT_ID as string,
|
||||
)
|
||||
expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
|
||||
200,
|
||||
)
|
||||
expect(body.access_token).toBeTruthy()
|
||||
accessToken = body.access_token as string
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("mints an OAuth access token that is not an sm_ API key", () => {
|
||||
expect(accessToken.startsWith("sm_")).toBe(false)
|
||||
})
|
||||
|
||||
it("connects to /mcp with the OAuth token and resolves identity", async () => {
|
||||
s = await connect({ token: accessToken })
|
||||
const res = await callTool(s.client, "whoAmI")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(JSON.parse(textOf(res)).userId).toBeTruthy()
|
||||
})
|
||||
},
|
||||
)
|
||||
80
apps/mcp/e2e/root-scope.test.ts
Normal file
80
apps/mcp/e2e/root-scope.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
|
||||
|
||||
type ToolLike = {
|
||||
name: string
|
||||
inputSchema?: { properties?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
|
||||
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
|
||||
|
||||
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
|
||||
const SCOPE_TAG = "sm_e2e_root"
|
||||
|
||||
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
|
||||
describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
|
||||
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
|
||||
const scoped = await connect({ containerTag: SCOPE_TAG })
|
||||
const plain = await connect()
|
||||
try {
|
||||
const scopedTools = (await scoped.client.listTools()).tools
|
||||
const plainTools = (await plain.client.listTools()).tools
|
||||
|
||||
expect(propsOf(plainTools, "memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag")
|
||||
|
||||
expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
|
||||
expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag")
|
||||
} finally {
|
||||
await scoped.close()
|
||||
await plain.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("scopes saves to the connection project and isolates them from default", async () => {
|
||||
const marker = `root-${randomUUID()}`
|
||||
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
|
||||
|
||||
const rooted = await connect({ containerTag: SCOPE_TAG })
|
||||
try {
|
||||
const save = await callTool(rooted.client, "memory", {
|
||||
content,
|
||||
action: "save",
|
||||
})
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toContain(SCOPE_TAG)
|
||||
|
||||
const found = await recallUntil(
|
||||
rooted.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
)
|
||||
expect(found, "marker not found within its root scope").not.toBeNull()
|
||||
} finally {
|
||||
await callTool(rooted.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
await rooted.close()
|
||||
}
|
||||
|
||||
// A default connection searches sm_project_default only — must not see it.
|
||||
const plain = await connect()
|
||||
try {
|
||||
const leaked = await recallUntil(
|
||||
plain.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
{
|
||||
tries: 3,
|
||||
delayMs: 3000,
|
||||
},
|
||||
)
|
||||
expect(leaked, "rooted memory leaked into the default project").toBeNull()
|
||||
} finally {
|
||||
await plain.close()
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
|
||||
"deploy": "vite build && wrangler deploy --minify",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"test:e2e": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "^0.2.2",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3480
apps/mcp/pnpm-lock.yaml
generated
Normal file
3480
apps/mcp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -103,6 +103,7 @@ export async function validateOAuthToken(
|
|||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
if (!sessionResponse.ok) {
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@ import Supermemory from "supermemory"
|
|||
|
||||
const MAX_CHARS = 200000 // ~50k tokens (character-based limit)
|
||||
const DEFAULT_PROJECT_ID = "sm_project_default"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
interface MemoryRichFields {
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
isAggregated?: boolean
|
||||
}
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
| ({
|
||||
id: string
|
||||
memory: string
|
||||
similarity: number
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
| {
|
||||
} & MemoryRichFields)
|
||||
| ({
|
||||
id: string
|
||||
chunk: string
|
||||
similarity: number
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
} & MemoryRichFields)
|
||||
|
||||
export interface SearchResult {
|
||||
results: Memory[]
|
||||
|
|
@ -25,6 +34,19 @@ export interface SearchResult {
|
|||
timing: number
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
searchMode?: "memories" | "hybrid" | "documents"
|
||||
rerank?: boolean
|
||||
rewriteQuery?: boolean
|
||||
include?: {
|
||||
documents?: boolean
|
||||
relatedMemories?: boolean
|
||||
summaries?: boolean
|
||||
chunks?: boolean
|
||||
forgottenMemories?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
static: string[]
|
||||
dynamic: string[]
|
||||
|
|
@ -98,7 +120,11 @@ interface SDKResult {
|
|||
content?: string
|
||||
similarity: number
|
||||
title?: string
|
||||
context?: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
isAggregated?: boolean
|
||||
}
|
||||
|
||||
export class SupermemoryClient {
|
||||
|
|
@ -117,6 +143,7 @@ export class SupermemoryClient {
|
|||
this.client = new Supermemory({
|
||||
apiKey: bearerToken,
|
||||
baseURL: apiUrl,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
})
|
||||
this.containerTag = containerTag || DEFAULT_PROJECT_ID
|
||||
}
|
||||
|
|
@ -218,26 +245,32 @@ export class SupermemoryClient {
|
|||
query: string,
|
||||
limit = 10,
|
||||
threshold?: number,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult> {
|
||||
try {
|
||||
const result = await this.client.search.memories({
|
||||
q: query,
|
||||
limit,
|
||||
containerTag: this.containerTag,
|
||||
searchMode: "hybrid",
|
||||
searchMode: options?.searchMode ?? "hybrid",
|
||||
threshold, // Optional threshold parameter
|
||||
rerank: options?.rerank,
|
||||
rewriteQuery: options?.rewriteQuery,
|
||||
include: options?.include,
|
||||
})
|
||||
|
||||
// Normalize and limit response size — preserve memory vs chunk distinction
|
||||
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const text = limitByChars(r.content || r.memory || r.chunk || "")
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
metadata: r.metadata,
|
||||
updatedAt: r.updatedAt,
|
||||
context: r.context,
|
||||
documents: r.documents,
|
||||
isAggregated: r.isAggregated,
|
||||
}
|
||||
if (r.chunk && !r.memory) {
|
||||
return { ...base, chunk: text }
|
||||
|
|
@ -273,9 +306,7 @@ export class SupermemoryClient {
|
|||
if (result.searchResults) {
|
||||
response.searchResults = {
|
||||
results: (result.searchResults.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const text = limitByChars(r.content || r.memory || r.chunk || "")
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
|
|
@ -299,14 +330,16 @@ export class SupermemoryClient {
|
|||
}
|
||||
|
||||
// Get projects list
|
||||
async getProjects(): Promise<string[]> {
|
||||
async getProjects(options?: { signal?: AbortSignal }): Promise<string[]> {
|
||||
try {
|
||||
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
const response = await fetch(`${this.apiUrl}/v3/projects`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -330,8 +363,10 @@ export class SupermemoryClient {
|
|||
containerTags?: string[],
|
||||
page = 1,
|
||||
limit = 10,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<DocumentsApiResponse> {
|
||||
try {
|
||||
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -345,6 +380,7 @@ export class SupermemoryClient {
|
|||
order: "desc",
|
||||
containerTags,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
|
|
@ -358,6 +394,14 @@ export class SupermemoryClient {
|
|||
}
|
||||
|
||||
private handleError(error: unknown): never {
|
||||
// Handle request timeout (AbortSignal.timeout or explicit abort)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.name === "TimeoutError")
|
||||
) {
|
||||
throw new Error("Request to Supermemory API timed out")
|
||||
}
|
||||
|
||||
// Handle network/fetch errors
|
||||
if (error instanceof TypeError) {
|
||||
if (
|
||||
|
|
|
|||
193
apps/mcp/src/format.test.ts
Normal file
193
apps/mcp/src/format.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import type { DocumentsApiResponse } from "./client"
|
||||
import { formatMemoriesList } from "./format"
|
||||
|
||||
function makeResponse(
|
||||
overrides: Partial<DocumentsApiResponse> = {},
|
||||
): DocumentsApiResponse {
|
||||
return {
|
||||
documents: [],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `mem_${memory.slice(0, 8)}`,
|
||||
memory,
|
||||
spaceId: "space_1",
|
||||
createdAt: "2026-06-10T12:00:00Z",
|
||||
updatedAt: "2026-06-10T12:00:00Z",
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatMemoriesList", () => {
|
||||
it("reports an empty store", () => {
|
||||
expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.")
|
||||
})
|
||||
|
||||
it("reports an out-of-range page distinctly from an empty store", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
pagination: {
|
||||
currentPage: 3,
|
||||
limit: 10,
|
||||
totalItems: 12,
|
||||
totalPages: 2,
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toBe("No documents on page 3 (2 pages total).")
|
||||
})
|
||||
|
||||
it("groups memories under their source document with title, type, and date", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Preferences",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("User prefers dark mode"),
|
||||
makeEntry("User works in TypeScript"),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain(
|
||||
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
|
||||
)
|
||||
expect(result).toContain('"Preferences" (text, 2026-06-12)')
|
||||
expect(result).toContain("- User prefers dark mode")
|
||||
expect(result).toContain("- User works in TypeScript")
|
||||
expect(result).not.toContain("More available")
|
||||
})
|
||||
|
||||
it("excludes forgotten and superseded memory entries", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Facts",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("Current fact"),
|
||||
makeEntry("Forgotten fact", { isForgotten: true }),
|
||||
makeEntry("Old version of a fact", { isLatest: false }),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("- Current fact")
|
||||
expect(result).not.toContain("Forgotten fact")
|
||||
expect(result).not.toContain("Old version of a fact")
|
||||
expect(result).toContain("1 memory across 1 document")
|
||||
})
|
||||
|
||||
it("marks documents whose extraction has not produced memories yet", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Still processing",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain(
|
||||
'"Still processing" (text, 2026-06-12) — no extracted memories yet',
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back to (untitled) for documents without a title", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: null,
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [makeEntry("Some fact")],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain('"(untitled)" (text, 2026-06-12)')
|
||||
})
|
||||
|
||||
it("flattens multi-line memories and truncates oversized ones", () => {
|
||||
const longMemory = `start ${"x".repeat(600)}`
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Big",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("line one\nline two\ttabbed"),
|
||||
makeEntry(longMemory),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("- line one line two tabbed")
|
||||
expect(result).toContain("… [truncated]")
|
||||
const truncatedLine = result
|
||||
.split("\n")
|
||||
.find((line) => line.includes("[truncated]"))
|
||||
expect(truncatedLine).toBeDefined()
|
||||
expect((truncatedLine as string).length).toBeLessThan(600)
|
||||
})
|
||||
|
||||
it("points at the next page when more documents exist", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Page one doc",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [makeEntry("A fact")],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("page 1 of 3, 3 documents total")
|
||||
expect(result).toContain("More available — call listMemories with page: 2.")
|
||||
})
|
||||
})
|
||||
210
apps/mcp/src/format.ts
Normal file
210
apps/mcp/src/format.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import type { DocumentsApiResponse } from "./client"
|
||||
|
||||
// Listing must stay lightweight: memory entries are extracted facts (short
|
||||
// strings), never raw document content, so responses fit comfortably in
|
||||
// client output limits even at the maximum page size.
|
||||
const MAX_LIST_MEMORY_CHARS = 500
|
||||
|
||||
export function formatMemoriesList(response: DocumentsApiResponse): string {
|
||||
const { documents, pagination } = response
|
||||
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
|
||||
|
||||
if (documents.length === 0) {
|
||||
return pagination.currentPage > 1
|
||||
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
|
||||
: "No memories stored yet."
|
||||
}
|
||||
|
||||
let memoryCount = 0
|
||||
const blocks = documents.map((doc) => {
|
||||
const activeEntries = doc.memoryEntries.filter(
|
||||
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
|
||||
)
|
||||
const title = doc.title?.trim() || "(untitled)"
|
||||
const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})`
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
return `${header} — no extracted memories yet`
|
||||
}
|
||||
|
||||
memoryCount += activeEntries.length
|
||||
const lines = activeEntries.map((entry) => {
|
||||
const text = entry.memory.replace(/\s+/g, " ").trim()
|
||||
return `- ${
|
||||
text.length > MAX_LIST_MEMORY_CHARS
|
||||
? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} … [truncated]`
|
||||
: text
|
||||
}`
|
||||
})
|
||||
return [header, ...lines].join("\n")
|
||||
})
|
||||
|
||||
const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.`
|
||||
|
||||
const parts = [header, "", blocks.join("\n\n")]
|
||||
if (pagination.currentPage < pagination.totalPages) {
|
||||
parts.push(
|
||||
"",
|
||||
`More available — call listMemories with page: ${pagination.currentPage + 1}.`,
|
||||
)
|
||||
}
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
export function formatMemories(
|
||||
response: { results?: Array<Record<string, unknown>>; total?: number },
|
||||
opts: {
|
||||
minSimilarity?: number
|
||||
maxRelations?: number
|
||||
maxDocuments?: number
|
||||
maxChunkLength?: number
|
||||
includeScores?: boolean
|
||||
includeLegend?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
minSimilarity = 0,
|
||||
maxRelations = 4,
|
||||
maxDocuments = 3,
|
||||
maxChunkLength = Number.POSITIVE_INFINITY,
|
||||
includeScores = true,
|
||||
includeLegend = true,
|
||||
} = opts
|
||||
|
||||
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
|
||||
const mime = (m: string | undefined) =>
|
||||
!m
|
||||
? ""
|
||||
: m === "application/pdf"
|
||||
? "pdf"
|
||||
: m.includes("spreadsheet")
|
||||
? "xlsx"
|
||||
: m.includes("presentation")
|
||||
? "pptx"
|
||||
: m.includes("document")
|
||||
? "doc"
|
||||
: (m.split("/").pop() ?? "")
|
||||
|
||||
const temporal = (tc: Record<string, unknown> | undefined) => {
|
||||
if (!tc) return [] as string[]
|
||||
const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
|
||||
return [
|
||||
tc.documentDate && `doc ${day(tc.documentDate as string)}`,
|
||||
ev.length === 1 && `event ${ev[0]}`,
|
||||
ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`,
|
||||
].filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
const describeMeta = (m: Record<string, unknown> | undefined | null) => {
|
||||
if (!m) return ""
|
||||
const tags = [
|
||||
mime(m.mimeType as string | undefined),
|
||||
m.source as string | undefined,
|
||||
...temporal(m.temporalContext as Record<string, unknown> | undefined),
|
||||
].filter(Boolean)
|
||||
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const renderRelations = (
|
||||
rels: Array<Record<string, unknown>> | undefined,
|
||||
arrow: string,
|
||||
root: string,
|
||||
) => {
|
||||
if (!rels?.length) return [] as string[]
|
||||
const seen = new Set<string>()
|
||||
const items = rels.filter((r) => {
|
||||
const k = (r.memory as string).trim()
|
||||
if (k === root.trim() || seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
const shown = items.slice(0, maxRelations)
|
||||
const lines = shown.map((r) => {
|
||||
const t = temporal(
|
||||
(r.metadata as Record<string, unknown> | undefined)?.temporalContext as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
)
|
||||
const when = t.length ? t.join(", ") : day(r.updatedAt as string)
|
||||
return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
|
||||
})
|
||||
if (items.length > shown.length)
|
||||
lines.push(` ${arrow} … +${items.length - shown.length} more`)
|
||||
return lines
|
||||
}
|
||||
|
||||
const renderDocs = (ds: Array<Record<string, unknown>> | undefined) =>
|
||||
(ds ?? []).slice(0, maxDocuments).map((d) => {
|
||||
const title = d.title ? `"${d.title}"` : "(untitled)"
|
||||
const type = d.type ? ` (${d.type})` : ""
|
||||
const summary = d.summary ? ` — ${d.summary}` : ""
|
||||
return ` Document: ${title}${type}${summary}`
|
||||
})
|
||||
|
||||
const results = (response.results ?? []).filter(
|
||||
(m) => ((m.similarity as number) ?? 0) >= minSimilarity,
|
||||
)
|
||||
if (!results.length) return "No relevant memories found."
|
||||
|
||||
const total = response.total ?? results.length
|
||||
const header = [
|
||||
`${results.length} memor${results.length === 1 ? "y" : "ies"}` +
|
||||
(total !== results.length ? ` of ${total}` : "") +
|
||||
", ranked by relevance.",
|
||||
includeLegend &&
|
||||
"Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const arrows = [
|
||||
["parents", "←"],
|
||||
["children", "→"],
|
||||
["related", "~"],
|
||||
] as const
|
||||
|
||||
const blocks = results.map((m) => {
|
||||
const score = (m.similarity as number)?.toFixed(2) ?? "—"
|
||||
const prefix = includeScores ? `${score} ` : ""
|
||||
const memory = (m.memory as string) ?? ""
|
||||
|
||||
if (m.isAggregated) return `${prefix}agg ${memory}`
|
||||
|
||||
if (m.chunk != null && m.memory == null) {
|
||||
const body = (m.chunk as string).replace(/\s+$/, "")
|
||||
const text =
|
||||
body.length > maxChunkLength
|
||||
? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
|
||||
: body
|
||||
return [
|
||||
`${prefix}chunk ${describeMeta(m.metadata as Record<string, unknown> | null)}`.trimEnd(),
|
||||
...renderDocs(
|
||||
m.documents as Array<Record<string, unknown>> | undefined,
|
||||
),
|
||||
...text.split("\n").map((l: string) => ` ${l}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
|
||||
const ctx = (m.context ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>
|
||||
return [
|
||||
`${prefix}${memory}`,
|
||||
meta
|
||||
? ` Source: ${meta}`
|
||||
: day(m.updatedAt as string)
|
||||
? ` Source: updated ${day(m.updatedAt as string)}`
|
||||
: null,
|
||||
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
|
||||
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
})
|
||||
|
||||
return [header, "", blocks.join("\n\n")].join("\n")
|
||||
}
|
||||
|
|
@ -89,6 +89,7 @@ app.get("/.well-known/oauth-authorization-server", async (c) => {
|
|||
// Fetch the authorization server metadata from the main API
|
||||
const response = await fetch(
|
||||
`${apiUrl}/.well-known/oauth-authorization-server`,
|
||||
{ signal: AbortSignal.timeout(30_000) },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import {
|
|||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SupermemoryClient, getMemoryText } from "./client"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { formatMemories, formatMemoriesList } from "./format"
|
||||
import { initPosthog, posthog } from "./posthog"
|
||||
import { z } from "zod"
|
||||
import mcpAppHtml from "../dist/mcp-app.html"
|
||||
|
|
@ -26,6 +27,8 @@ type Props = {
|
|||
|
||||
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const MAX_RECALL_CHARS = 200000
|
||||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTags: string[] = []
|
||||
|
|
@ -89,6 +92,27 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
...(hasRootContainerTag ? {} : containerTagField),
|
||||
})
|
||||
|
||||
const listMemoriesSchema = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe("Page number (1-based)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.default(10)
|
||||
.describe(
|
||||
"Documents per page; each document groups its extracted memories (default 10, max 50)",
|
||||
),
|
||||
...(hasRootContainerTag ? {} : containerTagField),
|
||||
})
|
||||
|
||||
const contextPromptSchema = z.object({
|
||||
includeRecent: z
|
||||
.boolean()
|
||||
|
|
@ -101,6 +125,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
type ContextPromptArgs = z.infer<typeof contextPromptSchema>
|
||||
type MemoryArgs = z.infer<typeof memorySchema>
|
||||
type RecallArgs = z.infer<typeof recallSchema>
|
||||
type ListMemoriesArgs = z.infer<typeof listMemoriesSchema>
|
||||
|
||||
// Register memory tool
|
||||
this.server.registerTool(
|
||||
|
|
@ -126,6 +151,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
(args: RecallArgs) => this.handleRecall(args),
|
||||
)
|
||||
|
||||
// Register listMemories tool
|
||||
this.server.registerTool(
|
||||
"listMemories",
|
||||
{
|
||||
description:
|
||||
"Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file — e.g. before forgetting stale memories or to power a 'list everything' view. For finding memories relevant to a topic, use 'recall' instead.",
|
||||
inputSchema: listMemoriesSchema,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
(args: ListMemoriesArgs) => this.handleListMemories(args),
|
||||
)
|
||||
|
||||
// Register profile resource
|
||||
this.server.registerResource(
|
||||
"User Profile",
|
||||
|
|
@ -628,10 +665,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
const clientInfo = await this.getClientInfo()
|
||||
const startTime = Date.now()
|
||||
|
||||
if (includeProfile) {
|
||||
const profileResult = await client.getProfile(query)
|
||||
const parts: string[] = []
|
||||
const searchResult = await client.search(query, 10, undefined, {
|
||||
searchMode: "hybrid",
|
||||
include: {
|
||||
documents: true,
|
||||
relatedMemories: true,
|
||||
summaries: false,
|
||||
chunks: false,
|
||||
forgottenMemories: false,
|
||||
},
|
||||
})
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (includeProfile) {
|
||||
const profileResult = await client.getProfile()
|
||||
if (
|
||||
profileResult.profile.static.length > 0 ||
|
||||
profileResult.profile.dynamic.length > 0
|
||||
|
|
@ -649,54 +697,23 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
parts.push(`- ${fact}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileResult.searchResults?.results.length) {
|
||||
parts.push("\n## Relevant Memories")
|
||||
for (const [
|
||||
i,
|
||||
memory,
|
||||
] of profileResult.searchResults.results.entries()) {
|
||||
parts.push(
|
||||
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
|
||||
)
|
||||
if (memory.title) parts.push(`**${memory.title}**`)
|
||||
parts.push(getMemoryText(memory))
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now()
|
||||
|
||||
// Track search event
|
||||
posthog
|
||||
.memorySearch({
|
||||
query_length: query.length,
|
||||
results_count: profileResult.searchResults?.results.length || 0,
|
||||
search_duration_ms: endTime - startTime,
|
||||
container_tags_count: 1,
|
||||
source: "mcp",
|
||||
userId: this.props?.userId || "unknown",
|
||||
mcp_client_name: clientInfo?.name,
|
||||
mcp_client_version: clientInfo?.version,
|
||||
sessionId: this.getMcpSessionId(),
|
||||
containerTag: containerTag || this.props?.containerTag,
|
||||
})
|
||||
.catch((error) => console.error("PostHog tracking error:", error))
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
parts.length > 0
|
||||
? parts.join("\n")
|
||||
: "No memories or profile found.",
|
||||
},
|
||||
],
|
||||
parts.push("")
|
||||
}
|
||||
}
|
||||
|
||||
const searchResult = await client.search(query, 10)
|
||||
parts.push("## Relevant Memories")
|
||||
parts.push(
|
||||
formatMemories(
|
||||
{
|
||||
results: searchResult.results as unknown as Array<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
total: searchResult.total,
|
||||
},
|
||||
{ includeScores: true, includeLegend: true },
|
||||
),
|
||||
)
|
||||
|
||||
const endTime = Date.now()
|
||||
|
||||
// Track search event
|
||||
|
|
@ -715,22 +732,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
})
|
||||
.catch((error) => console.error("PostHog tracking error:", error))
|
||||
|
||||
if (searchResult.results.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No memories found." }],
|
||||
}
|
||||
const text = parts.join("\n")
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
text.length > MAX_RECALL_CHARS
|
||||
? `${text.slice(0, MAX_RECALL_CHARS)}...`
|
||||
: text,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const parts = ["## Relevant Memories"]
|
||||
for (const [i, memory] of searchResult.results.entries()) {
|
||||
parts.push(
|
||||
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
|
||||
)
|
||||
if (memory.title) parts.push(`**${memory.title}**`)
|
||||
parts.push(getMemoryText(memory))
|
||||
}
|
||||
|
||||
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
|
|
@ -747,6 +760,46 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleListMemories(args: {
|
||||
page?: number
|
||||
limit?: number
|
||||
containerTag?: string
|
||||
}) {
|
||||
const { page = 1, limit = 10, containerTag } = args
|
||||
const effectiveContainerTag = containerTag || this.props?.containerTag
|
||||
|
||||
try {
|
||||
const client = this.getClient(effectiveContainerTag)
|
||||
const result = await client.getDocuments(
|
||||
effectiveContainerTag ? [effectiveContainerTag] : undefined,
|
||||
page,
|
||||
limit,
|
||||
)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: formatMemoriesList(result),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
console.error("List memories operation failed:", error)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error listing memories: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getClientInfo(): Promise<
|
||||
{ name: string; version?: string } | undefined
|
||||
> {
|
||||
|
|
|
|||
9
apps/mcp/vitest.config.ts
Normal file
9
apps/mcp/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
})
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@supermemory/memory-graph": "workspace:*",
|
||||
"next": "16.0.3",
|
||||
"next": "16.0.7",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { apiKey } = await request.json()
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const containerTagsUrl = new URL(
|
||||
"/v3/container-tags/list",
|
||||
SUPERMEMORY_API_BASE_URL,
|
||||
)
|
||||
|
||||
const response = await fetch(containerTagsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.message || `API error: ${response.status}` },
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Container tags API error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch container tags" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
|
@ -9,6 +11,7 @@ export async function POST(request: Request) {
|
|||
limit = 500,
|
||||
sort = "createdAt",
|
||||
order = "desc",
|
||||
containerTags,
|
||||
} = body
|
||||
|
||||
if (!apiKey) {
|
||||
|
|
@ -18,23 +21,28 @@ export async function POST(request: Request) {
|
|||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.supermemory.ai/v3/documents/documents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
order,
|
||||
}),
|
||||
},
|
||||
const graphUrl = new URL(
|
||||
"/v3/documents/documents",
|
||||
SUPERMEMORY_API_BASE_URL,
|
||||
)
|
||||
|
||||
const response = await fetch(graphUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
order,
|
||||
...(Array.isArray(containerTags) && containerTags.length > 0
|
||||
? { containerTags }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,52 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
MemoryGraph,
|
||||
type DocumentWithMemories,
|
||||
type GraphApiDocument,
|
||||
type GraphApiMemory,
|
||||
type GraphThemeColors,
|
||||
type MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
|
||||
|
||||
interface PlaygroundApiMemory {
|
||||
id: string
|
||||
memory?: string | null
|
||||
content?: string | null
|
||||
isStatic?: boolean
|
||||
spaceId?: string | null
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
spaceContainerTag?: string | null
|
||||
}
|
||||
|
||||
interface PlaygroundApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary?: string | null
|
||||
documentType?: string
|
||||
type?: string
|
||||
containerTags?: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memories?: PlaygroundApiMemory[]
|
||||
memoryEntries?: PlaygroundApiMemory[]
|
||||
}
|
||||
|
||||
interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
documents: PlaygroundApiDocument[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
|
|
@ -19,42 +55,82 @@ interface DocumentsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
interface ContainerTagOption {
|
||||
id: string
|
||||
name?: string | null
|
||||
containerTag: string
|
||||
documentCount?: number
|
||||
memoryCount?: number
|
||||
lastActivityAt?: string | null
|
||||
}
|
||||
|
||||
type GraphVariant = "consumer" | "console"
|
||||
type LoadBehavior = "zoom" | "manual" | "background"
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const BACKGROUND_LOAD_DELAY_MS = 900
|
||||
const CONSUMER_GRAPH_COLORS = {
|
||||
bg: "transparent",
|
||||
edgeDerives: "#9ca3af",
|
||||
} satisfies Partial<GraphThemeColors>
|
||||
|
||||
/** Convert the external API format to the internal graph format */
|
||||
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.content,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
|
||||
return docs.map((doc) => {
|
||||
const memories = doc.memories ?? doc.memoryEntries ?? []
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.documentType ?? doc.type ?? "unknown",
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? "",
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
}),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
|
||||
const [containerTag, setContainerTag] = useState("")
|
||||
const [containerTags, setContainerTags] = useState<ContainerTagOption[]>([])
|
||||
const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
|
||||
const [containerTagsError, setContainerTagsError] = useState<Error | null>(
|
||||
null,
|
||||
)
|
||||
const [documents, setDocuments] = useState<PlaygroundApiDocument[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [showGraph, setShowGraph] = useState(false)
|
||||
const [stressTestCount, setStressTestCount] = useState(0)
|
||||
const [graphVariant, setGraphVariant] = useState<GraphVariant>("consumer")
|
||||
const [loadBehavior, setLoadBehavior] = useState<LoadBehavior>("zoom")
|
||||
const [pagination, setPagination] = useState<
|
||||
DocumentsResponse["pagination"] | null
|
||||
>(null)
|
||||
|
||||
// State for slideshow
|
||||
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
|
||||
|
|
@ -64,13 +140,47 @@ export default function Home() {
|
|||
documents: GraphApiDocument[]
|
||||
} | null>(null)
|
||||
|
||||
const PAGE_SIZE = 500
|
||||
const selectedContainerTags = useMemo(() => {
|
||||
const trimmed = containerTag.trim()
|
||||
return trimmed ? [trimmed] : undefined
|
||||
}, [containerTag])
|
||||
|
||||
const fetchContainerTags = useCallback(async () => {
|
||||
if (!apiKey || isLoadingContainerTags) return
|
||||
|
||||
setIsLoadingContainerTags(true)
|
||||
setContainerTagsError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/container-tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Failed to fetch container tags")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ContainerTagOption[]
|
||||
setContainerTags(data)
|
||||
} catch (err) {
|
||||
setContainerTagsError(
|
||||
err instanceof Error ? err : new Error("Unknown error"),
|
||||
)
|
||||
} finally {
|
||||
setIsLoadingContainerTags(false)
|
||||
}
|
||||
}, [apiKey, isLoadingContainerTags])
|
||||
|
||||
const fetchDocuments = useCallback(
|
||||
async (page: number, append = false) => {
|
||||
if (!apiKey) return
|
||||
|
||||
if (page === 1) {
|
||||
if (append) {
|
||||
setIsLoadingMore(true)
|
||||
} else {
|
||||
setIsLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
|
@ -87,6 +197,7 @@ export default function Home() {
|
|||
limit: PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedContainerTags,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -103,6 +214,7 @@ export default function Home() {
|
|||
setDocuments(data.documents)
|
||||
}
|
||||
|
||||
setPagination(data.pagination)
|
||||
setShowGraph(true)
|
||||
setMockData(null)
|
||||
setStressTestCount(0)
|
||||
|
|
@ -110,19 +222,27 @@ export default function Home() {
|
|||
setError(err instanceof Error ? err : new Error("Unknown error"))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
},
|
||||
[apiKey],
|
||||
[apiKey, selectedContainerTags],
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (apiKey) {
|
||||
setDocuments([])
|
||||
setPagination(null)
|
||||
void fetchContainerTags()
|
||||
fetchDocuments(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadMoreDocuments = useCallback(() => {
|
||||
if (!pagination || pagination.currentPage >= pagination.totalPages) return
|
||||
fetchDocuments(pagination.currentPage + 1, true)
|
||||
}, [fetchDocuments, pagination])
|
||||
|
||||
const handleStressTest = (count: number) => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: count,
|
||||
|
|
@ -131,6 +251,7 @@ export default function Home() {
|
|||
})
|
||||
setMockData({ documents: data.documents })
|
||||
setDocuments([])
|
||||
setPagination(null)
|
||||
setStressTestCount(count)
|
||||
setShowGraph(true)
|
||||
setError(null)
|
||||
|
|
@ -157,7 +278,67 @@ export default function Home() {
|
|||
return toGraphDocuments(documents)
|
||||
}, [documents, mockData])
|
||||
|
||||
const availableContainerTags = useMemo(() => {
|
||||
const options = new Map<string, ContainerTagOption>()
|
||||
for (const tag of containerTags) {
|
||||
if (tag.containerTag) options.set(tag.containerTag, tag)
|
||||
}
|
||||
for (const doc of documents) {
|
||||
for (const tag of doc.containerTags ?? []) {
|
||||
if (tag && !options.has(tag)) {
|
||||
options.set(tag, { id: tag, containerTag: tag, name: tag })
|
||||
}
|
||||
}
|
||||
const memories = doc.memories ?? doc.memoryEntries ?? []
|
||||
for (const mem of memories) {
|
||||
const tag = mem.spaceContainerTag
|
||||
if (tag && !options.has(tag)) {
|
||||
options.set(tag, { id: tag, containerTag: tag, name: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
}, [containerTags, documents])
|
||||
|
||||
const displayCount = mockData ? stressTestCount : documents.length
|
||||
const hasMore =
|
||||
!mockData &&
|
||||
pagination != null &&
|
||||
pagination.currentPage < pagination.totalPages
|
||||
const totalCount = mockData
|
||||
? stressTestCount
|
||||
: (pagination?.totalItems ?? documents.length)
|
||||
const maxNodes = mockData ? 1000 : undefined
|
||||
const graphHandlesLoadMore = loadBehavior === "zoom"
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
loadBehavior !== "background" ||
|
||||
!showGraph ||
|
||||
mockData ||
|
||||
!hasMore ||
|
||||
isLoading ||
|
||||
isLoadingMore ||
|
||||
error
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(
|
||||
handleLoadMoreDocuments,
|
||||
BACKGROUND_LOAD_DELAY_MS,
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [
|
||||
error,
|
||||
handleLoadMoreDocuments,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
loadBehavior,
|
||||
mockData,
|
||||
showGraph,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950">
|
||||
|
|
@ -178,9 +359,49 @@ export default function Home() {
|
|||
type="password"
|
||||
placeholder="Enter your Supermemory API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
setContainerTags([])
|
||||
setContainerTagsError(null)
|
||||
}}
|
||||
className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
list="container-tag-options"
|
||||
value={containerTag}
|
||||
onChange={(e) => setContainerTag(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (availableContainerTags.length === 0) {
|
||||
void fetchContainerTags()
|
||||
}
|
||||
}}
|
||||
disabled={!apiKey}
|
||||
placeholder={
|
||||
isLoadingContainerTags
|
||||
? "Loading container tags..."
|
||||
: "All container tags"
|
||||
}
|
||||
className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<datalist id="container-tag-options">
|
||||
{availableContainerTags.map((tag) => (
|
||||
<option key={tag.containerTag} value={tag.containerTag}>
|
||||
{tag.name && tag.name !== tag.containerTag
|
||||
? `${tag.name} (${tag.containerTag})`
|
||||
: tag.containerTag}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchContainerTags()}
|
||||
disabled={!apiKey || isLoadingContainerTags}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoadingContainerTags ? "Loading..." : "Tags"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!apiKey || isLoading}
|
||||
|
|
@ -205,8 +426,80 @@ export default function Home() {
|
|||
Stress Test Mode
|
||||
</span>
|
||||
)}
|
||||
{pagination && !mockData && (
|
||||
<span className="font-mono text-xs text-zinc-500">
|
||||
Page {pagination.currentPage}/{pagination.totalPages}
|
||||
</span>
|
||||
)}
|
||||
{selectedContainerTags && (
|
||||
<span className="rounded bg-sky-950/70 px-2 py-0.5 font-mono text-xs text-sky-300">
|
||||
{selectedContainerTags[0]}
|
||||
</span>
|
||||
)}
|
||||
{containerTagsError && (
|
||||
<span className="text-xs text-amber-300">
|
||||
{containerTagsError.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-zinc-500 text-xs">Mode:</span>
|
||||
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
|
||||
{(["consumer", "console"] as const).map((variant) => (
|
||||
<button
|
||||
key={variant}
|
||||
type="button"
|
||||
onClick={() => setGraphVariant(variant)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
graphVariant === variant
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
}`}
|
||||
aria-pressed={graphVariant === variant}
|
||||
>
|
||||
{variant}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
<span className="text-zinc-500 text-xs">Load:</span>
|
||||
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
|
||||
{(["zoom", "manual", "background"] as const).map((behavior) => (
|
||||
<button
|
||||
key={behavior}
|
||||
type="button"
|
||||
onClick={() => setLoadBehavior(behavior)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
loadBehavior === behavior
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
}`}
|
||||
aria-pressed={loadBehavior === behavior}
|
||||
>
|
||||
{behavior}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loadBehavior === "manual" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMoreDocuments}
|
||||
disabled={!hasMore || isLoadingMore}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore
|
||||
? "Loading..."
|
||||
: hasMore
|
||||
? "Load next"
|
||||
: "All loaded"}
|
||||
</button>
|
||||
)}
|
||||
{loadBehavior === "background" && !mockData && hasMore && (
|
||||
<span className="rounded bg-emerald-950/70 px-2 py-0.5 text-xs text-emerald-300">
|
||||
Auto paging
|
||||
</span>
|
||||
)}
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
{/* Stress test buttons */}
|
||||
<span className="text-zinc-500 text-xs">Stress Test:</span>
|
||||
{[50, 100, 200, 500].map((count) => (
|
||||
|
|
@ -300,13 +593,24 @@ export default function Home() {
|
|||
<MemoryGraph
|
||||
documents={graphDocuments}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={graphHandlesLoadMore && hasMore}
|
||||
onLoadMore={
|
||||
graphHandlesLoadMore && hasMore
|
||||
? handleLoadMoreDocuments
|
||||
: undefined
|
||||
}
|
||||
error={error}
|
||||
variant="consumer"
|
||||
maxNodes={1000}
|
||||
variant={graphVariant}
|
||||
maxNodes={maxNodes}
|
||||
showFps={stressTestCount > 0}
|
||||
isSlideshowActive={isSlideshowActive}
|
||||
onSlideshowNodeChange={handleSlideshowNodeChange}
|
||||
onSlideshowStop={handleSlideshowStop}
|
||||
totalCount={totalCount}
|
||||
colors={
|
||||
graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-zinc-400">
|
||||
|
|
|
|||
992
apps/web/app/(app)/brain-home/page.tsx
Normal file
992
apps/web/app/(app)/brain-home/page.tsx
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { Header } from "@/components/header"
|
||||
import { HomeChatComposer } from "@/components/chat/home-chat-composer"
|
||||
import {
|
||||
RecommendedRail,
|
||||
SourcesHealthRail,
|
||||
} from "@/components/brain-home/widgets"
|
||||
|
||||
const modalCardStyle = {
|
||||
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",
|
||||
}
|
||||
|
||||
const inputBevelStyle = {
|
||||
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)",
|
||||
}
|
||||
|
||||
type Space = {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
itemCount: number
|
||||
lastSync: string
|
||||
}
|
||||
|
||||
const _SPACES: Space[] = [
|
||||
{
|
||||
id: "drive",
|
||||
name: "My Drive",
|
||||
icon: <GoogleDrive className="size-4" />,
|
||||
itemCount: 287,
|
||||
lastSync: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Company Notion",
|
||||
icon: <Notion className="size-4" />,
|
||||
itemCount: 142,
|
||||
lastSync: "12m ago",
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
icon: <Mail className="size-4 text-[#EA4335]" />,
|
||||
itemCount: 1843,
|
||||
lastSync: "Just now",
|
||||
},
|
||||
]
|
||||
|
||||
type Stat = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
deltaTint?: "up" | "down" | "neutral"
|
||||
}
|
||||
|
||||
const STATS: Stat[] = [
|
||||
{
|
||||
label: "Items ingested",
|
||||
value: "2,272",
|
||||
delta: "+148 this week",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Agent sessions",
|
||||
value: "184",
|
||||
delta: "+42 vs last wk",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Active contributors",
|
||||
value: "4",
|
||||
delta: "of 6 invited",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Top space",
|
||||
value: "Gmail",
|
||||
delta: "1,843 items",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
]
|
||||
|
||||
type AgentSeries = {
|
||||
key: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const AGENT_SERIES: AgentSeries[] = [
|
||||
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
|
||||
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
|
||||
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
|
||||
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
|
||||
{ key: "other", label: "Other", color: "#525D6E" },
|
||||
]
|
||||
|
||||
const SESSIONS_BY_DAY: {
|
||||
label: string
|
||||
values: Record<string, number>
|
||||
}[] = [
|
||||
{
|
||||
label: "Mon",
|
||||
values: {
|
||||
cursor: 8,
|
||||
claudeCode: 4,
|
||||
claudeDesktop: 3,
|
||||
chatgpt: 2,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Tue",
|
||||
values: {
|
||||
cursor: 10,
|
||||
claudeCode: 6,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Wed",
|
||||
values: {
|
||||
cursor: 12,
|
||||
claudeCode: 9,
|
||||
claudeDesktop: 5,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Thu",
|
||||
values: {
|
||||
cursor: 9,
|
||||
claudeCode: 5,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Fri",
|
||||
values: {
|
||||
cursor: 14,
|
||||
claudeCode: 10,
|
||||
claudeDesktop: 6,
|
||||
chatgpt: 4,
|
||||
other: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sat",
|
||||
values: {
|
||||
cursor: 5,
|
||||
claudeCode: 3,
|
||||
claudeDesktop: 2,
|
||||
chatgpt: 1,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sun",
|
||||
values: {
|
||||
cursor: 17,
|
||||
claudeCode: 12,
|
||||
claudeDesktop: 7,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const RECENT_ANSWERS = [
|
||||
{
|
||||
question: "What did we decide about pricing?",
|
||||
answer: "Pro stays at $20/mo. No overages, top-up only.",
|
||||
who: "You",
|
||||
when: "2h ago",
|
||||
},
|
||||
{
|
||||
question: "What's blocking the Acme deal?",
|
||||
answer: "MSA review — Jane drafting a net-30 counter.",
|
||||
who: "You",
|
||||
when: "4h ago",
|
||||
},
|
||||
{
|
||||
question: "Who owns the onboarding rewrite?",
|
||||
answer: "Design team. Ship target Friday.",
|
||||
who: "Sarah Kim",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
const WEEKLY_DIGEST = {
|
||||
range: "Mon – Fri",
|
||||
headline:
|
||||
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
|
||||
bullets: [
|
||||
{
|
||||
label: "5 deadlines coming up next week",
|
||||
tint: "text-[#4BA0FA]",
|
||||
},
|
||||
{
|
||||
label: "2 conflicts surfaced between Notion and Slack",
|
||||
tint: "text-[#FF8A47]",
|
||||
},
|
||||
{
|
||||
label: "Sarah Kim led contributions with 14 docs",
|
||||
tint: "text-[#A1A1AA]",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const TEAM_THIS_WEEK = [
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
email: "sarah@acme.com",
|
||||
contributions: 14,
|
||||
summary: "14 docs · 3 spaces",
|
||||
color: "#FF8A47",
|
||||
},
|
||||
{
|
||||
name: "Jane Doe",
|
||||
email: "jane@acme.com",
|
||||
contributions: 11,
|
||||
summary: "11 docs · 2 spaces",
|
||||
color: "#4BA0FA",
|
||||
},
|
||||
{
|
||||
name: "Mahesh S.",
|
||||
email: "mahesh@acme.com",
|
||||
contributions: 9,
|
||||
summary: "9 docs · 1 space",
|
||||
color: "#A1A1AA",
|
||||
},
|
||||
{
|
||||
name: "Alex Chen",
|
||||
email: "alex@acme.com",
|
||||
contributions: 7,
|
||||
summary: "7 docs · 2 spaces",
|
||||
color: "#10A37F",
|
||||
},
|
||||
]
|
||||
|
||||
const TEAM_RECENT = [
|
||||
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
|
||||
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
|
||||
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
|
||||
{
|
||||
who: "Sarah Kim",
|
||||
what: "connected Notion to Sales space",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIVITY = [
|
||||
{
|
||||
when: "2m",
|
||||
text: "23 new docs ingested from Drive",
|
||||
source: "drive",
|
||||
},
|
||||
{
|
||||
when: "14m",
|
||||
text: "Jane added a memo to acme-deal-q2",
|
||||
source: "notion",
|
||||
},
|
||||
{
|
||||
when: "1h",
|
||||
text: "Decision surfaced: pricing locked at $20 Pro",
|
||||
source: "slack",
|
||||
},
|
||||
{
|
||||
when: "3h",
|
||||
text: "14 Notion pages updated by Sarah Kim",
|
||||
source: "notion",
|
||||
},
|
||||
{
|
||||
when: "5h",
|
||||
text: "New entity formed: pricing-v3",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
when: "Yesterday",
|
||||
text: "MSA contract uploaded · auto-tagged contracts/MSA",
|
||||
source: "drive",
|
||||
},
|
||||
]
|
||||
|
||||
const _RECENT_ITEMS: Record<string, { title: string; meta: string }[]> = {
|
||||
drive: [
|
||||
{ title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" },
|
||||
{ title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" },
|
||||
{ title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" },
|
||||
{ title: "Customer feedback Q1.docx", meta: "Yesterday" },
|
||||
{ title: "Brand assets / logos", meta: "2 days ago" },
|
||||
],
|
||||
notion: [
|
||||
{ title: "Engineering / Weekly sync", meta: "Updated 12m ago" },
|
||||
{ title: "Sales / Acme account", meta: "Updated 1h ago" },
|
||||
{ title: "Brand / Voice & tone", meta: "Yesterday" },
|
||||
{ title: "Onboarding rewrite spec", meta: "2 days ago" },
|
||||
],
|
||||
gmail: [
|
||||
{ title: "Re: Acme contract review", meta: "Just now · jane@acme.com" },
|
||||
{ title: "Customer feedback digest", meta: "10m ago" },
|
||||
{ title: "Re: Pricing decision", meta: "1h ago" },
|
||||
{ title: "Onboarding ship plan", meta: "3h ago" },
|
||||
],
|
||||
}
|
||||
|
||||
export default function BrainHomePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<BrainHomeInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainHomeInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const empty = searchParams?.get("empty") === "1"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh bg-[#05080D] text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={false}
|
||||
topPosition="20%"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
|
||||
<div
|
||||
id="brain-home-grid"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.22)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Header onAddMemory={() => {}} onOpenSearch={() => {}} />
|
||||
|
||||
<main className="relative z-10 px-6 md:px-10 py-8 pb-[180px]">
|
||||
<div className="max-w-[1280px] mx-auto space-y-6">
|
||||
<StatsStrip stats={STATS} empty={empty} />
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_320px] gap-6">
|
||||
<div className="space-y-8 min-w-0">
|
||||
<BrainPulseBlock empty={empty} />
|
||||
<BrainActivityChart empty={empty} />
|
||||
<ActivityFeedBlock empty={empty} />
|
||||
</div>
|
||||
|
||||
<aside className="lg:sticky lg:top-6 lg:self-start h-fit space-y-4">
|
||||
<TeamActivityRail empty={empty} />
|
||||
<SourcesHealthRail empty={empty} />
|
||||
<RecommendedRail />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-56 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30">
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) {
|
||||
const EMPTY_STATS: Stat[] = [
|
||||
{
|
||||
label: "Items ingested",
|
||||
value: "—",
|
||||
delta: "Connect a source",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Agent sessions",
|
||||
value: "—",
|
||||
delta: "Install an agent",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Active contributors",
|
||||
value: "1",
|
||||
delta: "Just you so far",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Top space",
|
||||
value: "—",
|
||||
delta: "Nothing connected",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
]
|
||||
const visible = empty ? EMPTY_STATS : stats
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{visible.map((s) => (
|
||||
<div key={s.label} className="px-5 py-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{s.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold mt-1.5 tabular-nums leading-none",
|
||||
empty && s.value === "—" ? "text-[#525D6E]" : "text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
{s.delta && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-medium mt-1.5",
|
||||
s.deltaTint === "up"
|
||||
? "text-[#4BA0FA]"
|
||||
: s.deltaTint === "down"
|
||||
? "text-[#FF8A47]"
|
||||
: "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{s.delta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainActivityChart({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sessions by agent
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
0 sessions yet — once you install Cursor, Claude Code, or any
|
||||
MCP-capable agent, usage shows up here.
|
||||
</p>
|
||||
<div className="mt-6 flex items-end gap-2 opacity-30" aria-hidden>
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
|
||||
<div key={d} className="flex-1 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-[4px] bg-[#1F2937]"
|
||||
style={{ height: "4px" }}
|
||||
/>
|
||||
<span className="text-[10px] text-[#525D6E] font-medium">
|
||||
{d}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 text-[12px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Install an agent <ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const totalsByAgent = AGENT_SERIES.map((s) => ({
|
||||
...s,
|
||||
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
|
||||
}))
|
||||
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
|
||||
const dayTotals = SESSIONS_BY_DAY.map((d) =>
|
||||
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
|
||||
)
|
||||
const max = Math.max(...dayTotals)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sessions by agent
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
{grandTotal} agent sessions, last 7 days — which tool the team
|
||||
reached for.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
|
||||
{totalsByAgent.map((s) => (
|
||||
<span
|
||||
key={s.key}
|
||||
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
{s.label}{" "}
|
||||
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{SESSIONS_BY_DAY.map((d, dayIdx) => {
|
||||
const BAR_AREA_PX = 140
|
||||
const totalForDay = dayTotals[dayIdx] ?? 0
|
||||
const barHeightPx = Math.max(
|
||||
6,
|
||||
Math.round((totalForDay / max) * BAR_AREA_PX),
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={d.label}
|
||||
className="flex-1 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-full flex items-end"
|
||||
style={{ height: `${BAR_AREA_PX}px` }}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-[4px] overflow-hidden flex flex-col"
|
||||
style={{ height: `${barHeightPx}px` }}
|
||||
title={`${d.label}: ${totalForDay} sessions`}
|
||||
>
|
||||
{AGENT_SERIES.map((series, sIdx) => {
|
||||
const val = d.values[series.key] ?? 0
|
||||
if (val === 0) return null
|
||||
const segHeight =
|
||||
totalForDay > 0 ? (val / totalForDay) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
key={series.key}
|
||||
style={{
|
||||
height: `${segHeight}%`,
|
||||
background: series.color,
|
||||
}}
|
||||
className={cn(
|
||||
sIdx === 0 && "rounded-t-[4px]",
|
||||
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
|
||||
)}
|
||||
title={`${series.label} · ${val}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function _TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-2 rounded-full px-3 text-[12px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-white/[0.10] text-[#fafafa]"
|
||||
: "text-[#A1A1AA] hover:text-[#fafafa] hover:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="leading-none">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold tabular-nums leading-none",
|
||||
active ? "text-[#A1A1AA]" : "text-[#525D6E]",
|
||||
)}
|
||||
>
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ title, cta }: { title: string; cta?: string }) {
|
||||
return (
|
||||
<div className="flex items-end justify-between mb-3 px-1">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{title}
|
||||
</p>
|
||||
{cta && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainPulseBlock({ empty }: { empty?: boolean }) {
|
||||
return (
|
||||
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
|
||||
<RecentAnswersCard empty={empty} />
|
||||
<WeeklyDigestCard empty={empty} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentAnswersCard({ empty }: { empty?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
Recent answers
|
||||
</p>
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
See all →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{empty ? (
|
||||
<div className="flex-1 flex flex-col items-start justify-center py-4 gap-1.5">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium">
|
||||
No questions asked yet.
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium leading-[1.5]">
|
||||
Ask the brain something below — answers and the team's history will
|
||||
show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{RECENT_ANSWERS.map((a) => (
|
||||
<li
|
||||
key={a.question}
|
||||
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
|
||||
>
|
||||
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
|
||||
{a.question}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
|
||||
→ {a.answer}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#737373] font-medium mt-1">
|
||||
{a.who} · {a.when}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeeklyDigestCard({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-3">
|
||||
This week
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold text-[#fafafa] leading-snug",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Your brain is just getting started.
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium leading-[1.5] mt-2">
|
||||
Once sources are connected and the team starts asking questions,
|
||||
you'll see decisions, deadlines, and contributor highlights here.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto pt-4 self-start text-[11px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Connect a source <ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
|
||||
{WEEKLY_DIGEST.range}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold text-[#fafafa] leading-snug",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
|
||||
/^\d+$/.test(piece) ? (
|
||||
<span key={i} className="text-[#4BA0FA]">
|
||||
{piece}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{piece}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{WEEKLY_DIGEST.bullets.map((b) => (
|
||||
<li key={b.label} className="flex items-start gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
|
||||
/>
|
||||
<span className={cn("text-[12px] font-medium", b.tint)}>
|
||||
{b.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Open weekly digest <ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityFeedBlock({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Activity feed" />
|
||||
<div
|
||||
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-6 flex items-start gap-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
|
||||
<Sparkles className="size-3.5 text-[#525D6E]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium">
|
||||
Nothing happening yet.
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-1 leading-[1.5]">
|
||||
As sources sync and the team starts using the brain, every event
|
||||
will show up here in real time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const sourceIcon = (s: string) => {
|
||||
if (s === "drive") return <GoogleDrive className="size-3.5" />
|
||||
if (s === "notion") return <Notion className="size-3.5" />
|
||||
if (s === "slack")
|
||||
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
|
||||
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Activity feed" cta="Show more →" />
|
||||
<div
|
||||
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{ACTIVITY.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
|
||||
{sourceIcon(a.source)}
|
||||
</div>
|
||||
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
|
||||
{a.text}
|
||||
</p>
|
||||
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
|
||||
{a.when}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamActivityRail({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Just you so far
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
|
||||
A brain gets sharper as more people contribute. Invite teammates to
|
||||
see who's adding what.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Invite teammates
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Team activity
|
||||
</p>
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#737373] font-medium">
|
||||
What your team's been adding to the brain.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{TEAM_THIS_WEEK.map((member) => (
|
||||
<div
|
||||
key={member.email}
|
||||
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div
|
||||
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
|
||||
style={{ background: member.color }}
|
||||
>
|
||||
{member.name[0]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium truncate">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium truncate">
|
||||
{member.summary}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
|
||||
{member.contributions}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-white/[0.04]">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
|
||||
Recent
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{TEAM_RECENT.map((event, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
|
||||
>
|
||||
<span className="text-[#fafafa]">{event.who}</span> {event.what}
|
||||
<span className="text-[#525D6E]"> · {event.when}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Invite teammates
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
250
apps/web/app/(app)/connect/page.tsx
Normal file
250
apps/web/app/(app)/connect/page.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { OAUTH_PLUGINS } from "@/lib/oauth-plugins"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Building2, ExternalLink, LoaderIcon, Plug, Trash2 } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
interface Connection {
|
||||
clientId: string
|
||||
name: string
|
||||
icon: string | null
|
||||
isFirstParty: boolean
|
||||
workspaceId: string | null
|
||||
workspaceName: string | null
|
||||
scopes: string[]
|
||||
connectedAt: string | null
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return null
|
||||
const diff = Date.now() - then
|
||||
const mins = Math.round(diff / 60000)
|
||||
if (mins < 1) return "just now"
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.round(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
const months = Math.round(days / 30)
|
||||
if (months < 12) return `${months}mo ago`
|
||||
return `${Math.round(months / 12)}y ago`
|
||||
}
|
||||
|
||||
const cardClass = cn(
|
||||
"rounded-[14px] bg-[#14161A] p-5",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)
|
||||
|
||||
function PluginIcon({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
|
||||
<Plug className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[8px] border border-[#1E293B] bg-[#080B0F]">
|
||||
<Image
|
||||
alt={alt}
|
||||
className="size-5"
|
||||
height={20}
|
||||
onError={() => setFailed(true)}
|
||||
src={src}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
const [connections, setConnections] = useState<Connection[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revoking, setRevoking] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/v3/oauth/grants`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to load connections (${res.status})`)
|
||||
const data = (await res.json()) as { grants: Connection[] }
|
||||
setConnections(data.grants)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("Failed to load connections:", err)
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load connections",
|
||||
)
|
||||
setConnections([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function revoke(clientId: string) {
|
||||
setRevoking(clientId)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/v3/oauth/grants/${encodeURIComponent(clientId)}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (!res.ok && res.status !== 204)
|
||||
throw new Error(`Failed to revoke (${res.status})`)
|
||||
setConnections((prev) =>
|
||||
prev ? prev.filter((c) => c.clientId !== clientId) : prev,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error("Failed to revoke connection:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to revoke")
|
||||
} finally {
|
||||
setRevoking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const connectedClientIds = new Set(connections?.map((c) => c.clientId) ?? [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-[760px] px-4 py-10",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<h1 className="font-semibold text-[22px] text-[#FAFAFA]">Connections</h1>
|
||||
<p className="mt-1 text-[14px] text-[#737373]">
|
||||
Apps and plugins you've connected to your Supermemory account.
|
||||
</p>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
|
||||
Connected apps
|
||||
</h2>
|
||||
|
||||
{connections === null ? (
|
||||
<div className={cn(cardClass, "flex items-center gap-3")}>
|
||||
<LoaderIcon className="size-4 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[14px] text-[#737373]">Loading…</span>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className={cn(cardClass, "text-center")}>
|
||||
<p className="text-[14px] text-[#FAFAFA]">No apps connected yet</p>
|
||||
<p className="mt-1 text-[13px] text-[#737373]">
|
||||
Connect a plugin below — anything you authorize will show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{connections.map((c) => {
|
||||
const connectedRel = relativeTime(c.connectedAt)
|
||||
const usedRel = relativeTime(c.lastUsedAt)
|
||||
return (
|
||||
<div
|
||||
key={c.clientId}
|
||||
className={cn(cardClass, "flex items-center gap-4")}
|
||||
>
|
||||
<PluginIcon src={c.icon} alt={c.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-[15px] text-[#FAFAFA]">
|
||||
{c.name}
|
||||
</p>
|
||||
{!c.isFirstParty && (
|
||||
<span className="rounded-full border border-[#1E293B] px-1.5 py-0.5 text-[10px] text-[#737373] uppercase tracking-[0.04em]">
|
||||
external
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[12px] text-[#737373]">
|
||||
{c.workspaceName && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="size-3" />
|
||||
{c.workspaceName}
|
||||
</span>
|
||||
)}
|
||||
{connectedRel && <span>Connected {connectedRel}</span>}
|
||||
{usedRel && <span>Last used {usedRel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => revoke(c.clientId)}
|
||||
disabled={revoking === c.clientId}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-[8px] border border-[#1E293B] bg-[#0D121A] px-3 py-1.5",
|
||||
"text-[13px] text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
|
||||
"cursor-pointer disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{revoking === c.clientId ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-2 text-[13px] text-red-400">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-[13px] text-[#737373] uppercase tracking-[0.06em]">
|
||||
Available plugins
|
||||
</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{OAUTH_PLUGINS.map((p) => {
|
||||
const isConnected =
|
||||
p.oauthClientId != null && connectedClientIds.has(p.oauthClientId)
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={cn(cardClass, "flex flex-col gap-3 p-4")}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginIcon src={p.icon} alt={p.name} />
|
||||
<p className="flex-1 truncate text-[15px] text-[#FAFAFA]">
|
||||
{p.name}
|
||||
</p>
|
||||
{isConnected && (
|
||||
<span className="rounded-full border border-[#1f4d44] bg-[#0c1c19] px-2 py-0.5 text-[10px] text-[#7fd9c4] uppercase tracking-[0.04em]">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[13px] text-[#8B8B8B] leading-snug">
|
||||
{p.description}
|
||||
</p>
|
||||
<a
|
||||
href={p.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-fit items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
Setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal file
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { AppExperience } from "@/components/app-experience"
|
||||
import { isIntegrationCard } from "@/lib/integration-routes"
|
||||
|
||||
export default async function IntegrationCardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ card: string }>
|
||||
}) {
|
||||
const { card } = await params
|
||||
if (!isIntegrationCard(card)) notFound()
|
||||
return <AppExperience />
|
||||
}
|
||||
5
apps/web/app/(app)/integrations/page.tsx
Normal file
5
apps/web/app/(app)/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AppExperience } from "@/components/app-experience"
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return <AppExperience />
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsModalProvider>
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<PWAInstallPrompt />
|
||||
</>
|
||||
</SettingsModalProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,810 +1,5 @@
|
|||
"use client"
|
||||
import { AppExperience } from "@/components/app-experience"
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { MobileBottomNav } from "@/components/bottom-nav"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
|
||||
import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { DocumentModal } from "@/components/document-modal"
|
||||
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
||||
import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
|
||||
import type { HighlightItem } from "@/components/highlights-card"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import {
|
||||
useQuickNoteDraftReset,
|
||||
useQuickNoteDraft,
|
||||
} from "@/stores/quick-note-draft"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import type { MemoryOfDay } from "@/components/dashboard-view"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
addDocumentParam,
|
||||
searchParam,
|
||||
qParam,
|
||||
docParam,
|
||||
fullscreenParam,
|
||||
threadParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { getToolDocumentSpace } from "@/lib/plugin-space"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
function subscribeViewportWidth(cb: () => void) {
|
||||
window.addEventListener("resize", cb)
|
||||
return () => window.removeEventListener("resize", cb)
|
||||
}
|
||||
|
||||
function getViewportWidth() {
|
||||
return window.innerWidth
|
||||
}
|
||||
|
||||
const GRADIENT_TOP_WIDTH_MAX = 1440
|
||||
|
||||
function gradientTopPositionForWidth(width: number) {
|
||||
const minW = 320
|
||||
const pctWide = 15
|
||||
const pctNarrow = 55
|
||||
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
|
||||
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
|
||||
const eased = t * t
|
||||
return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
|
||||
}
|
||||
|
||||
function ViewErrorFallback() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">
|
||||
Something went wrong.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
|
||||
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const { allProjects } = useContainerTags()
|
||||
const dashboardSpaceLabel = useMemo(
|
||||
() =>
|
||||
getChatSpaceDisplayLabel({
|
||||
selectedProject,
|
||||
allProjects,
|
||||
}),
|
||||
[selectedProject, allProjects],
|
||||
)
|
||||
const emptyStateSpaceName = selectedProjectTag
|
||||
? selectedProjectTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
|
||||
selectedProjectTag)
|
||||
: undefined
|
||||
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const queryClient = useQueryClient()
|
||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||
|
||||
// Chrome extension auth: send session token via postMessage so the content script can store it
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (!url.searchParams.get("extension-auth-success")) return
|
||||
const sessionToken = session?.token
|
||||
const userData = { email: user?.email, name: user?.name, userId: user?.id }
|
||||
if (sessionToken && userData.email) {
|
||||
window.postMessage(
|
||||
{ token: encodeURIComponent(sessionToken), userData },
|
||||
window.location.origin,
|
||||
)
|
||||
url.searchParams.delete("extension-auth-success")
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}
|
||||
}, [user, session])
|
||||
|
||||
// URL-driven modal states
|
||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
|
||||
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
|
||||
const [docId, setDocId] = useQueryState("doc", docParam)
|
||||
const [isFullscreen, setIsFullscreen] = useQueryState(
|
||||
"fullscreen",
|
||||
fullscreenParam,
|
||||
)
|
||||
const [, setThreadIdUrl] = useQueryState("thread", threadParam)
|
||||
|
||||
// Ephemeral local state (not worth URL-encoding)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
|
||||
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [queuedMessageSource, setQueuedMessageSource] = useState<
|
||||
"highlight" | "home"
|
||||
>("highlight")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
|
||||
// Clear document when docId is removed (e.g. back button)
|
||||
useEffect(() => {
|
||||
if (!docId) setSelectedDocument(null)
|
||||
}, [docId])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === "dashboard") void setThreadIdUrl(null)
|
||||
}, [viewMode, setThreadIdUrl])
|
||||
|
||||
// Resolve document from cache when loading with ?doc=<id> (deep link / refresh)
|
||||
useEffect(() => {
|
||||
if (!docId || selectedDocument) return
|
||||
|
||||
const tryResolve = () => {
|
||||
const queries = queryClient.getQueriesData<{
|
||||
pages: DocumentsResponse[]
|
||||
}>({ queryKey: ["documents-with-memories"] })
|
||||
for (const [, data] of queries) {
|
||||
if (!data?.pages) continue
|
||||
for (const page of data.pages) {
|
||||
const doc = page.documents?.find((d) => d.id === docId)
|
||||
if (doc) {
|
||||
setSelectedDocument(doc)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (tryResolve()) return
|
||||
|
||||
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
|
||||
if (tryResolve()) unsubscribe()
|
||||
})
|
||||
return unsubscribe
|
||||
}, [docId, selectedDocument, queryClient])
|
||||
|
||||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
|
||||
const quickNoteDraftRef = useRef(quickNoteDraft)
|
||||
quickNoteDraftRef.current = quickNoteDraft
|
||||
|
||||
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
resetDraft()
|
||||
setIsFullscreen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
|
||||
const handleToggleSelection = useCallback((documentId: string) => {
|
||||
setSelectedDocumentIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(documentId)) {
|
||||
next.delete(documentId)
|
||||
} else {
|
||||
next.add(documentId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedDocumentIds(new Set())
|
||||
setIsSelectionMode(false)
|
||||
}, [])
|
||||
|
||||
const handleEnterSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode(true)
|
||||
}, [])
|
||||
|
||||
const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
|
||||
setSelectedDocumentIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of visibleIds) {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const ids = Array.from(selectedDocumentIds)
|
||||
if (ids.length === 0) return
|
||||
bulkDeleteMutation.mutate(
|
||||
{ documentIds: ids },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedDocumentIds(new Set())
|
||||
setIsSelectionMode(false)
|
||||
if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
|
||||
setDocId(null)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
|
||||
|
||||
type SpaceHighlightsResponse = {
|
||||
highlights: HighlightItem[]
|
||||
questions: string[]
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
|
||||
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
const handleResetHighlights = useCallback(async () => {
|
||||
toast.success("Refreshing daily brief…")
|
||||
try {
|
||||
await caches.delete(HIGHLIGHTS_CACHE_NAME)
|
||||
} catch {}
|
||||
setHighlightsForceAt(Date.now())
|
||||
}, [])
|
||||
|
||||
const { data: highlightsData, isLoading: isLoadingHighlights } =
|
||||
useQuery<SpaceHighlightsResponse>({
|
||||
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
|
||||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const spaceId = selectedProject || "sm_project_default"
|
||||
const forceRefresh = highlightsForceAt > 0
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
spaceId,
|
||||
highlightsCount: 3,
|
||||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
forceRefresh,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch space highlights")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Update browser cache with fresh data (works for both normal and forced refresh)
|
||||
try {
|
||||
const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await freshCache.put(cacheKey, cacheResponse)
|
||||
} catch {}
|
||||
|
||||
// Reset force flag after the forced fetch completes so future project-switches
|
||||
// use the normal cache path instead of always bypassing it.
|
||||
if (forceRefresh) setHighlightsForceAt(0)
|
||||
|
||||
return data
|
||||
},
|
||||
staleTime: HIGHLIGHTS_MAX_AGE,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
|
||||
queryKey: [
|
||||
"memory-of-day",
|
||||
user?.id,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
],
|
||||
queryFn: async (): Promise<MemoryOfDay | null> => {
|
||||
const cacheKey = `memory-of-day:v2:${user?.id}:${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
const stored = localStorage.getItem(cacheKey)
|
||||
if (stored) return JSON.parse(stored) as MemoryOfDay
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as MemoryOfDay | null
|
||||
if (data) {
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(data))
|
||||
} catch {}
|
||||
}
|
||||
return data
|
||||
},
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
useHotkeys("c", () => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
})
|
||||
useHotkeys("mod+k", (e) => {
|
||||
e.preventDefault()
|
||||
analytics.searchOpened({ source: "hotkey" })
|
||||
setIsSearchOpen(true)
|
||||
})
|
||||
|
||||
const handleOpenDocument = useCallback(
|
||||
(document: DocumentWithMemories) => {
|
||||
if (document.id) {
|
||||
analytics.documentModalOpened({ document_id: document.id })
|
||||
setSelectedDocument(document)
|
||||
setDocId(document.id)
|
||||
}
|
||||
},
|
||||
[setDocId],
|
||||
)
|
||||
|
||||
const handleOpenToolDocument = useCallback(
|
||||
(document: DocumentWithMemories, pluginClientId: string) => {
|
||||
const documentSpace = getToolDocumentSpace(document, pluginClientId)
|
||||
if (documentSpace) {
|
||||
setSelectedProject(documentSpace)
|
||||
}
|
||||
handleOpenDocument(document)
|
||||
void setViewMode("list")
|
||||
},
|
||||
[handleOpenDocument, setSelectedProject, setViewMode],
|
||||
)
|
||||
|
||||
// Separate from handleOpenDocument because the graph view only has a document ID,
|
||||
// not the full document object. The modal will fetch the document via the docId
|
||||
// query param, so there may be a brief loading state (unlike handleOpenDocument
|
||||
// which pre-populates via setSelectedDocument).
|
||||
const handleOpenDocumentById = useCallback(
|
||||
(documentId: string) => {
|
||||
analytics.documentModalOpened({ document_id: documentId })
|
||||
setDocId(documentId)
|
||||
},
|
||||
[setDocId],
|
||||
)
|
||||
|
||||
const handleQuickNoteSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
|
||||
noteMutation.mutate(
|
||||
{ content, project: selectedProject },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (hadPreviousContent) {
|
||||
analytics.quickNoteEdited()
|
||||
} else {
|
||||
analytics.quickNoteCreated()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation],
|
||||
)
|
||||
|
||||
const handleFullScreenSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
const hadInitialContent = fullscreenInitialContent.trim().length > 0
|
||||
noteMutation.mutate(
|
||||
{ content, project: selectedProject },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (hadInitialContent) {
|
||||
analytics.quickNoteEdited()
|
||||
} else {
|
||||
analytics.quickNoteCreated()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation, fullscreenInitialContent],
|
||||
)
|
||||
|
||||
const handleMaximize = useCallback(
|
||||
(content: string) => {
|
||||
analytics.fullscreenNoteModalOpened()
|
||||
setFullscreenInitialContent(content)
|
||||
setIsFullscreen(true)
|
||||
},
|
||||
[setIsFullscreen],
|
||||
)
|
||||
|
||||
const handleHighlightsChat = useCallback(
|
||||
(highlightContent: string, userReply: string) => {
|
||||
setQueuedHighlightContent(highlightContent)
|
||||
setQueuedChatSeed(userReply)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const handleHomeChatStart = useCallback(
|
||||
(message: string, model: ModelId, projectId: string) => {
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedChatProject(projectId)
|
||||
setQueuedMessageSource("home")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const consumeQueuedChat = useCallback(() => {
|
||||
setQueuedChatSeed(null)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
||||
const handleHighlightsShowRelated = useCallback(
|
||||
(query: string) => {
|
||||
analytics.searchOpened({ source: "highlight_related" })
|
||||
setSearchPrefill(query)
|
||||
setIsSearchOpen(true)
|
||||
},
|
||||
[setSearchPrefill, setIsSearchOpen],
|
||||
)
|
||||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
if (integration === "notion" || integration === "google-drive") {
|
||||
void setAddDoc("connect")
|
||||
return
|
||||
}
|
||||
void setViewMode(integration ?? "integrations")
|
||||
},
|
||||
[setViewMode, setAddDoc],
|
||||
)
|
||||
|
||||
const handleOpenPlugins = useCallback(() => {
|
||||
void setViewMode("plugins")
|
||||
}, [setViewMode])
|
||||
|
||||
const handleAddMemory = useCallback(
|
||||
(tab: "note" | "link") => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc(tab)
|
||||
},
|
||||
[setAddDoc],
|
||||
)
|
||||
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeViewportWidth,
|
||||
getViewportWidth,
|
||||
() => GRADIENT_TOP_WIDTH_MAX,
|
||||
)
|
||||
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
|
||||
|
||||
const isChatView = viewMode === "chat"
|
||||
const showNovaBackdrop =
|
||||
viewMode === "graph" || viewMode === "list" || viewMode === "dashboard"
|
||||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !isChatView && !!session
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
>
|
||||
{showNovaBackdrop && (
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={false}
|
||||
topPosition={gradientTopPosition}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!session && viewMode === "mcp" ? (
|
||||
<PublicHeader />
|
||||
) : (
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={`main-container-${viewMode}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col",
|
||||
(isGraphMode || isChatView) && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
||||
{isChatView ? (
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
|
||||
<ChatSidebar
|
||||
layout="page"
|
||||
isChatOpen
|
||||
setIsChatOpen={(open) => {
|
||||
if (!open) void setViewMode("dashboard")
|
||||
}}
|
||||
queuedMessage={queuedChatSeed}
|
||||
queuedHighlightContent={queuedHighlightContent}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
initialChatProject={queuedChatProject}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "integrations" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "plugins" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<PluginsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "chrome" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ChromeDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "shortcuts" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ShortcutsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "raycast" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<RaycastDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "import" ? (
|
||||
<XBookmarksDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
|
||||
"pb-10 md:pb-12",
|
||||
)}
|
||||
>
|
||||
<MemoriesGrid
|
||||
isChatOpen={false}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSelectAllVisible={handleSelectAllVisible}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
isBulkDeleting={bulkDeleteMutation.isPending}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
emptyStateProps={{
|
||||
onAddMemory: handleAddMemory,
|
||||
onOpenIntegrations: handleOpenIntegrations,
|
||||
isAllSpaces: false,
|
||||
spaceName: emptyStateSpaceName,
|
||||
onSwitchToAllSpaces: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardView
|
||||
spaceLabel={dashboardSpaceLabel}
|
||||
headerNotice={undefined}
|
||||
highlights={highlightsData?.highlights ?? []}
|
||||
isLoadingHighlights={isLoadingHighlights}
|
||||
onAddMemory={handleAddMemory}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
onOpenIntegrations={handleOpenIntegrations}
|
||||
onOpenPlugins={handleOpenPlugins}
|
||||
onNavigateToMemories={() => void setViewMode("list")}
|
||||
onNavigateToGraph={() => void setViewMode("graph")}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenToolDocument={handleOpenToolDocument}
|
||||
onHighlightsChat={handleHighlightsChat}
|
||||
onHighlightsShowRelated={handleHighlightsShowRelated}
|
||||
onResetHighlights={handleResetHighlights}
|
||||
memoryOfDay={memoryOfDay}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && showBottomNav && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
||||
)}
|
||||
{isDashboardShell && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
showBottomNav
|
||||
? "bottom-[4.25rem]"
|
||||
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBottomNav && (
|
||||
<MobileBottomNav
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
isOpen={addDoc !== null}
|
||||
onClose={() => setAddDoc(null)}
|
||||
/>
|
||||
<DocumentsCommandPalette
|
||||
open={isSearchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSearchOpen(open)
|
||||
if (!open) setSearchPrefill("")
|
||||
}}
|
||||
projectId={selectedProject}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenIntegrations={() => setViewMode("integrations")}
|
||||
initialSearch={searchPrefill}
|
||||
/>
|
||||
<DocumentModal
|
||||
document={selectedDocument}
|
||||
isOpen={docId !== null}
|
||||
onClose={() => setDocId(null)}
|
||||
/>
|
||||
<FullscreenNoteModal
|
||||
isOpen={isFullscreen}
|
||||
onClose={() => setIsFullscreen(false)}
|
||||
initialContent={fullscreenInitialContent}
|
||||
onSave={handleFullScreenSave}
|
||||
isSaving={noteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</HotkeysProvider>
|
||||
)
|
||||
export default function Page() {
|
||||
return <AppExperience />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function SettingsIntegrationsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const qs = searchParams.toString()
|
||||
router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`)
|
||||
}, [router, searchParams])
|
||||
|
||||
return null
|
||||
redirect("/integrations")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,738 +1,23 @@
|
|||
"use client"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Sun,
|
||||
LoaderIcon,
|
||||
User as UserIcon,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
ShieldAlert,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Building2,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import {
|
||||
PLAN_DISPLAY_NAMES,
|
||||
useTokenUsage,
|
||||
type PlanType,
|
||||
} from "@/hooks/use-token-usage"
|
||||
import { parseHashToTab } from "@/components/settings/settings-content"
|
||||
|
||||
const TABS = [
|
||||
"account",
|
||||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"support",
|
||||
] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
||||
type NavItem = {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account",
|
||||
description: "Your profile and organization",
|
||||
icon: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
? (cleaned as SettingsTab)
|
||||
: "account"
|
||||
}
|
||||
|
||||
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
|
||||
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
|
||||
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
|
||||
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
|
||||
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
|
||||
}
|
||||
|
||||
function OrgPlanBadge({ plan }: { plan: PlanType }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
||||
ORG_PLAN_BADGE_STYLES[plan],
|
||||
)}
|
||||
>
|
||||
{PLAN_DISPLAY_NAMES[plan]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOrgPlan(
|
||||
orgId: string,
|
||||
isCurrent: boolean,
|
||||
currentPlan: PlanType,
|
||||
planByOrgId: Map<string, PlanType>,
|
||||
): PlanType {
|
||||
const fromSummary = planByOrgId.get(orgId)
|
||||
if (fromSummary) return fromSummary
|
||||
if (isCurrent) return currentPlan
|
||||
return "free"
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentityCard({ displayName }: { displayName: string }) {
|
||||
const firstName = displayName?.split(" ")[0] || ""
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
/**
|
||||
* Legacy redirect: /settings (and /settings#billing etc.) now open the
|
||||
* settings modal via the ?settings=<tab> URL state on the home page.
|
||||
*/
|
||||
export default function SettingsRedirect() {
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
|
||||
const [resetConfirmation, setResetConfirmation] = useState("")
|
||||
const resetOrganization = useResetOrganization()
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
|
||||
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const canSwitchOrg = (organizations?.length ?? 0) > 1
|
||||
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
for (const summary of orgSummaries ?? []) {
|
||||
map.set(summary.orgId, summary.plan)
|
||||
}
|
||||
return map
|
||||
}, [orgSummaries])
|
||||
const activeOrgPlan = org?.id
|
||||
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
|
||||
: currentPlan
|
||||
|
||||
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
|
||||
if (orgId === org?.id) {
|
||||
setOrgSwitcherOpen(false)
|
||||
return
|
||||
}
|
||||
setSwitchingOrgId(orgId)
|
||||
try {
|
||||
await setActiveOrg(orgSlug)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("Failed to switch organization:", error)
|
||||
setSwitchingOrgId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteEmailConfirm !== user?.email) return
|
||||
deleteUserAccount.mutate(
|
||||
{ confirmation: deleteEmailConfirm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteEmailConfirm("")
|
||||
router.push("/login")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return
|
||||
hasInitialized.current = true
|
||||
|
||||
const hash = window.location.hash
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : ""
|
||||
const tab = parseHashToTab(hash)
|
||||
setActiveTab(tab)
|
||||
analytics.settingsTabChanged({ tab })
|
||||
router.replace(
|
||||
tab === "integrations" ? "/integrations" : `/?settings=${tab}`,
|
||||
)
|
||||
}, [router])
|
||||
|
||||
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
|
||||
window.history.pushState(null, "", "#account")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const tab = parseHashToTab(window.location.hash)
|
||||
setActiveTab(tab)
|
||||
analytics.settingsTabChanged({ tab })
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const headerDisplayName =
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
|
||||
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className={cn(
|
||||
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
|
||||
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
<span className="ml-2 font-semibold text-white/90 tracking-tight">
|
||||
supermemory
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-white/30 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/55 font-medium shrink-0">Settings</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
{!isMobile && (
|
||||
<Popover
|
||||
open={orgSwitcherOpen && canSwitchOrg}
|
||||
onOpenChange={(open) => {
|
||||
if (canSwitchOrg) setOrgSwitcherOpen(open)
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSwitchOrg}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
canSwitchOrg
|
||||
? "cursor-pointer hover:bg-white/[0.06]"
|
||||
: "cursor-default",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
{canSwitchOrg && (
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{[...(organizations ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
handleOrgSwitch(organization.slug, organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<UserProfileMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
|
||||
{!isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={headerDisplayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
|
||||
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
analytics.settingsTabChanged({ tab: item.id })
|
||||
}}
|
||||
className={cn(
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">
|
||||
Reset data
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
<p className="text-muted-foreground p-4">
|
||||
Something went wrong loading this section.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => setResetConfirmation(e.target.value)}
|
||||
placeholder={confirmText || "Your name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsResetDialogOpen(false)
|
||||
setResetConfirmation("")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{resetOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
|
||||
placeholder={user?.email ?? "your@email.com"}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,531 +1,26 @@
|
|||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
return `${backend}/api/auth/mcp/authorize?${p.toString()}`
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
q.set(key, value)
|
||||
}
|
||||
}
|
||||
return q.toString()
|
||||
}
|
||||
|
||||
function AnimatedGradientBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden bg-[#030912]">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [1, 0, 1],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex py-8 px-11 flex-col items-start gap-2 rounded-[22px] bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (isMcpOAuthAuthorizeContext(sp)) {
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
return
|
||||
}
|
||||
const redirectUrl = params.get("redirect")
|
||||
if (redirectUrl) {
|
||||
window.location.assign(
|
||||
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
router.replace("/")
|
||||
}, [
|
||||
sessionPending,
|
||||
sessionData?.session,
|
||||
oauthQueryForResume,
|
||||
params,
|
||||
router,
|
||||
])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative h-screen overflow-hidden">
|
||||
<AnimatedGradientBackground />
|
||||
<div className="relative z-10">
|
||||
<InitialHeader />
|
||||
<section className="flex flex-col items-center justify-center p-4 space-y-12 sm:p-6 md:p-8 lg:px-20 lg:py-12.5 min-h-[calc(100vh-80px)]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-medium">
|
||||
Never forget anything, anywhere
|
||||
</div>
|
||||
<div className="text-5xl font-medium">with supermemory</div>
|
||||
</div>
|
||||
{submittedEmail ? (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col gap-4 lg:gap-6 min-h-2/3">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">{submittedEmail}</span>.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button disabled={isLoading} id="verify-token" type="submit">
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginCard>
|
||||
) : (
|
||||
<LoginCard>
|
||||
<div className="w-[360px] flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "google" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="relative grow">
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{lastUsedMethod === "github" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator text="OR" className={cn(dmSansClassName())} />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: isLoading,
|
||||
id: "email",
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
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)",
|
||||
}}
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
{isLoadingEmail
|
||||
? "Sending login link..."
|
||||
: "Log in with Supermemory"}
|
||||
</Button>
|
||||
{lastUsedMethod === "magic_link" && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
</LoginCard>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login?${query}` : "/login")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,558 @@
|
|||
import { redirect } from "next/navigation"
|
||||
"use client"
|
||||
|
||||
function serializeSearchParams(
|
||||
sp: Record<string, string | string[] | undefined>,
|
||||
import { signIn, useSession } from "@lib/auth"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import { TextSeparator } from "@ui/components/text-separator"
|
||||
import { ExternalAuthButton } from "@ui/button/external-auth"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Badge } from "@ui/components/badge"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"
|
||||
import { Label1Regular } from "@ui/text/label/label-1-regular"
|
||||
import { Title1Bold } from "@ui/text/title/title-1-bold"
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { LoginToolsPanel } from "@/components/login-tools-panel"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { getBackendUrl, resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
return sp.get("response_type") === "code" && Boolean(sp.get("client_id"))
|
||||
}
|
||||
|
||||
function buildMcpAuthorizeResumeUrl(
|
||||
sp: Pick<URLSearchParams, "toString">,
|
||||
): string {
|
||||
const q = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(sp)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) q.append(key, v)
|
||||
} else {
|
||||
q.set(key, value)
|
||||
}
|
||||
const backend =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const p = new URLSearchParams(sp.toString())
|
||||
p.delete("redirect")
|
||||
p.delete("error")
|
||||
p.delete("exp")
|
||||
p.delete("sig")
|
||||
|
||||
const prompt = p
|
||||
.get("prompt")
|
||||
?.split(" ")
|
||||
.filter((value) => value && value !== "login")
|
||||
if (prompt?.length) {
|
||||
p.set("prompt", prompt.join(" "))
|
||||
} else {
|
||||
p.delete("prompt")
|
||||
}
|
||||
return q.toString()
|
||||
|
||||
return `${backend}/api/auth/oauth2/authorize?${p.toString()}`
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const sp = await searchParams
|
||||
const query = serializeSearchParams(sp)
|
||||
redirect(query ? `/login/new?${query}` : "/login/new")
|
||||
function LoginHeadline({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("max-w-sm text-center", className)}>
|
||||
<h1 className="text-balance text-lg font-medium leading-tight tracking-tight sm:text-xl lg:text-[1.375rem] xl:text-2xl">
|
||||
Never forget anything, anywhere
|
||||
<span className="block text-brand-accent">with supermemory</span>
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-xs leading-snug text-muted-foreground/50 lg:mt-2",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Save from Chrome, Notion, X — search it all in one place.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LastUsedBadge({ show }: { show: boolean }) {
|
||||
if (!show) return null
|
||||
return (
|
||||
<div className="mb-1 flex justify-end">
|
||||
<Badge variant="default" className="h-5 px-1.5 text-[10px]">
|
||||
Last used
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function LoginCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex w-full max-w-[400px] shrink-0 flex-col items-center gap-2 rounded-[22px] py-5 px-5 sm:w-auto sm:max-w-none sm:items-start sm:py-8 sm:px-11 bg-linear-to-b from-[#06101F] to-[#030912] shadow-[1.5px_1.5px_20px_0_rgba(0,0,0,0.65),1px_1.5px_2px_0_rgba(128,189,255,0.07)_inset,-0.5px_-1.5px_4px_0_rgba(0,35,73,0.40)_inset]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginCardBody({
|
||||
children,
|
||||
loadingMessage,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loadingMessage?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full max-w-[360px] sm:w-[360px]">
|
||||
<div className={loadingMessage ? "invisible" : undefined}>{children}</div>
|
||||
{loadingMessage ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-[14px] bg-[#030912]/90 backdrop-blur-sm">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submittedEmail, setSubmittedEmail] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingEmail, setIsLoadingEmail] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUsedMethod, setLastUsedMethod] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const posthog = usePostHog()
|
||||
|
||||
const params = useSearchParams()
|
||||
const { data: sessionData, isPending: sessionPending } = useSession()
|
||||
|
||||
const oauthQueryForResume = params.toString()
|
||||
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
|
||||
const isAuthResolving = sessionPending || isRedirecting
|
||||
const loadingMessage = isAuthResolving
|
||||
? sessionPending
|
||||
? "Checking session…"
|
||||
: "Redirecting…"
|
||||
: isLoading
|
||||
? isLoadingEmail
|
||||
? "Sending login link…"
|
||||
: "Redirecting…"
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!sessionData?.session) return
|
||||
const sp = new URLSearchParams(oauthQueryForResume)
|
||||
if (isMcpOAuthAuthorizeContext(sp)) {
|
||||
window.location.assign(buildMcpAuthorizeResumeUrl(sp))
|
||||
return
|
||||
}
|
||||
const redirectUrl = params.get("redirect")
|
||||
if (redirectUrl) {
|
||||
window.location.assign(
|
||||
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
// Carry the flag so the dashboard posts the session token to the extension (else: sign-in loop).
|
||||
const dest = new URL("/", window.location.origin)
|
||||
dest.searchParams.set("extension-auth-success", "true")
|
||||
window.location.assign(dest.toString())
|
||||
}, [sessionPending, sessionData?.session, oauthQueryForResume, params])
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = params.get("redirect")
|
||||
|
||||
// Create callback URL that includes redirect parameter if provided
|
||||
const getCallbackURL = () => {
|
||||
const origin = window.location.origin
|
||||
|
||||
if (isMcpOAuthAuthorizeContext(params)) {
|
||||
return buildMcpAuthorizeResumeUrl(params)
|
||||
}
|
||||
|
||||
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
|
||||
|
||||
finalUrl.searchParams.set("extension-auth-success", "true")
|
||||
return finalUrl.toString()
|
||||
}
|
||||
|
||||
// Load last used method from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMethod = localStorage.getItem("supermemory-last-login-method")
|
||||
setLastUsedMethod(savedMethod)
|
||||
}, [])
|
||||
|
||||
// Record the pending login method (will be committed after successful auth)
|
||||
function setPendingLoginMethod(method: string) {
|
||||
try {
|
||||
localStorage.setItem("supermemory-pending-login-method", method)
|
||||
localStorage.setItem(
|
||||
"supermemory-pending-login-timestamp",
|
||||
String(Date.now()),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes("load failed") ||
|
||||
message.includes("networkerror") ||
|
||||
message.includes("failed to fetch") ||
|
||||
message.includes("network request failed")
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (isNetworkError(error)) {
|
||||
return "Network error. Please check your connection and try again."
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
|
||||
// If we land back on this page with an error, clear any pending marker
|
||||
useEffect(() => {
|
||||
if (params.get("error")) {
|
||||
try {
|
||||
localStorage.removeItem("supermemory-pending-login-method")
|
||||
localStorage.removeItem("supermemory-pending-login-timestamp")
|
||||
} catch {}
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setIsLoadingEmail(true)
|
||||
setError(null)
|
||||
|
||||
// Track login attempt
|
||||
posthog.capture("login_attempt", {
|
||||
method: "magic_link",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
posthog.capture("login_failed", {
|
||||
method: "magic_link",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
is_network_error: isNetworkError(error),
|
||||
})
|
||||
|
||||
setError(getErrorMessage(error))
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
||||
const handleSubmitToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page-grid relative bg-[#030912]">
|
||||
<div className="login-page-mobile-bg" aria-hidden>
|
||||
<div className="login-panel-orb h-full" />
|
||||
<div className="login-panel-orb-image h-full" />
|
||||
</div>
|
||||
<div className="relative z-20 lg:hidden">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<LoginToolsPanel />
|
||||
<div className="relative z-10 flex min-h-0 min-w-0 flex-col lg:col-start-2 lg:row-start-1 lg:h-full">
|
||||
<div className="hidden lg:block">
|
||||
<InitialHeader />
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col items-center px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pt-1 lg:justify-center lg:px-10 lg:py-10">
|
||||
<div className="mt-auto flex w-full flex-col items-center lg:mt-0">
|
||||
<LoginHeadline className="mb-3 sm:mb-4 lg:mb-8" />
|
||||
<LoginCard>
|
||||
<LoginCardBody loadingMessage={loadingMessage}>
|
||||
{submittedEmail ? (
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<div className="flex flex-col gap-2 text-center lg:text-left">
|
||||
<Title1Bold className="text-foreground">
|
||||
Almost there!
|
||||
</Title1Bold>
|
||||
<HeadingH3Medium className="text-muted-foreground">
|
||||
Click the magic link we've sent to{" "}
|
||||
<span className="text-foreground">
|
||||
{submittedEmail}
|
||||
</span>
|
||||
.
|
||||
</HeadingH3Medium>
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 lg:gap-6"
|
||||
onSubmit={handleSubmitToken}
|
||||
>
|
||||
<LabeledInput
|
||||
inputPlaceholder="your temporary login code"
|
||||
inputProps={{
|
||||
name: "token",
|
||||
required: true,
|
||||
disabled: isLoading,
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
}}
|
||||
inputType="text"
|
||||
label="Enter code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
id="verify-token"
|
||||
type="submit"
|
||||
>
|
||||
Verify Token
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col" style={{ gap: "12px" }}>
|
||||
{params.get("error") && (
|
||||
<div className="text-red-500">
|
||||
Error: {params.get("error")}. Please try again!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "google"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Google</title>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#FFC107"
|
||||
/>
|
||||
<path
|
||||
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
|
||||
fill="#FF3D00"
|
||||
/>
|
||||
<path
|
||||
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
|
||||
fill="#4CAF50"
|
||||
/>
|
||||
<path
|
||||
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
|
||||
fill="#1976D2"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Google"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "google",
|
||||
})
|
||||
setPendingLoginMethod("google")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
!process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "github"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 26 25"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Github</title>
|
||||
<g clipPath="url(#clip0_2579_3356)">
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2579_3356">
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
transform="translate(0.75 0.21)"
|
||||
width="24.5"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
authProvider="Github"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "github",
|
||||
})
|
||||
setPendingLoginMethod("github")
|
||||
signIn
|
||||
.social({
|
||||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
text="OR"
|
||||
className={cn(dmSansClassName())}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<LabeledInput
|
||||
error={error}
|
||||
inputPlaceholder="your@email.com"
|
||||
inputProps={{
|
||||
"aria-invalid": error ? "true" : "false",
|
||||
disabled: Boolean(loadingMessage),
|
||||
id: "email",
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setEmail(e.target.value)
|
||||
error && setError(null)
|
||||
},
|
||||
required: true,
|
||||
value: email,
|
||||
}}
|
||||
inputType="email"
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
<LastUsedBadge
|
||||
show={lastUsedMethod === "magic_link"}
|
||||
/>
|
||||
<Button
|
||||
className="flex justify-center items-center w-full h-[44px] relative gap-3 p-2 rounded-xl"
|
||||
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)",
|
||||
}}
|
||||
disabled={Boolean(loadingMessage)}
|
||||
type="submit"
|
||||
>
|
||||
<Logo className="size-4" />
|
||||
Log in with Supermemory
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Label1Regular
|
||||
className={cn(
|
||||
"text-center text-xs! text-[#737373B2]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
By continuing, you agree to our{" "}
|
||||
<span className="inline-block">
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/terms/"
|
||||
>
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://supermemory.ai/privacy/"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</Label1Regular>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LoginCardBody>
|
||||
</LoginCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,26 +13,137 @@ function isValidUrl(urlString: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function isPrivateIPv4Octets(a: number, b: number): boolean {
|
||||
// 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
|
||||
if (a === 0) return true
|
||||
if (a === 10) return true
|
||||
if (a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 100 && b >= 64 && b <= 127) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse an IPv6 hostname into its eight 16-bit groups. The WHATWG URL parser
|
||||
// already canonicalizes literals, but it leaves the brackets on (e.g. "[::1]")
|
||||
// and performs no SSRF filtering. Handles "::" compression and embedded IPv4
|
||||
// suffixes (e.g. ::ffff:1.2.3.4). Returns null when not an IPv6 literal.
|
||||
function parseIPv6Groups(input: string): number[] | null {
|
||||
let host = input
|
||||
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1)
|
||||
const zone = host.indexOf("%") // strip scope/zone id, e.g. fe80::1%eth0
|
||||
if (zone !== -1) host = host.slice(0, zone)
|
||||
if (!host.includes(":")) return null
|
||||
|
||||
// Convert an embedded IPv4 tail (e.g. ::ffff:1.2.3.4) into two hex groups.
|
||||
const dot = host.indexOf(".")
|
||||
if (dot !== -1) {
|
||||
const colon = host.lastIndexOf(":", dot)
|
||||
if (colon === -1) return null
|
||||
const octets = host.slice(colon + 1).split(".")
|
||||
if (octets.length !== 4) return null
|
||||
const bytes: number[] = []
|
||||
for (const octet of octets) {
|
||||
if (!/^\d{1,3}$/.test(octet)) return null
|
||||
const n = Number(octet)
|
||||
if (n > 255) return null
|
||||
bytes.push(n)
|
||||
}
|
||||
const hi = (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)).toString(16)
|
||||
const lo = (((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0)).toString(16)
|
||||
host = `${host.slice(0, colon + 1)}${hi}:${lo}`
|
||||
}
|
||||
|
||||
const parseSide = (side: string): number[] | null => {
|
||||
if (side === "") return []
|
||||
const groups: number[] = []
|
||||
for (const group of side.split(":")) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
|
||||
groups.push(Number.parseInt(group, 16))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const halves = host.split("::")
|
||||
if (halves.length > 2) return null
|
||||
|
||||
if (halves.length === 2) {
|
||||
const head = parseSide(halves[0] ?? "")
|
||||
const tail = parseSide(halves[1] ?? "")
|
||||
if (!head || !tail) return null
|
||||
const missing = 8 - head.length - tail.length
|
||||
if (missing < 1) return null
|
||||
return [...head, ...new Array<number>(missing).fill(0), ...tail]
|
||||
}
|
||||
|
||||
const all = parseSide(host)
|
||||
if (all?.length !== 8) return null
|
||||
return all
|
||||
}
|
||||
|
||||
function isPrivateIPv6(input: string): boolean {
|
||||
const g = parseIPv6Groups(input)
|
||||
if (!g) return false
|
||||
|
||||
// Unspecified (::) and loopback (::1)
|
||||
if (g.every((x) => x === 0)) return true
|
||||
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true
|
||||
|
||||
// Link-local fe80::/10
|
||||
if (((g[0] ?? 0) & 0xffc0) === 0xfe80) return true
|
||||
|
||||
// Unique local address fc00::/7 (fc00–fdff)
|
||||
if ((((g[0] ?? 0) >> 8) & 0xfe) === 0xfc) return true
|
||||
|
||||
// IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d):
|
||||
// re-check the embedded IPv4 against the private ranges so loopback/metadata
|
||||
// can't be reached via an IPv6 wrapper.
|
||||
const firstFiveZero = g.slice(0, 5).every((x) => x === 0)
|
||||
const ipv4Mapped = firstFiveZero && g[5] === 0xffff
|
||||
const ipv4Compatible =
|
||||
firstFiveZero && g[5] === 0 && (g[6] !== 0 || g[7] !== 0)
|
||||
if (ipv4Mapped || ipv4Compatible) {
|
||||
const a = ((g[6] ?? 0) >> 8) & 0xff
|
||||
const b = (g[6] ?? 0) & 0xff
|
||||
return isPrivateIPv4Octets(a, b)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NOTE: This blocks private/loopback/metadata IP *literals* only. It cannot stop
|
||||
// DNS rebinding (a public hostname that resolves to a private address), which
|
||||
// would require resolving + pinning the address before connecting — not available
|
||||
// in the edge fetch runtime here.
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
const lowerHost = hostname.toLowerCase()
|
||||
|
||||
if (
|
||||
lowerHost === "localhost" ||
|
||||
lowerHost === "127.0.0.1" ||
|
||||
lowerHost === "::1" ||
|
||||
lowerHost.startsWith("127.") ||
|
||||
lowerHost.startsWith("0.0.0.0")
|
||||
) {
|
||||
// Hostname-based loopback (RFC 6761 reserves localhost and *.localhost)
|
||||
if (lowerHost === "localhost" || lowerHost.endsWith(".localhost")) {
|
||||
return true
|
||||
}
|
||||
|
||||
const privateIpPatterns = [
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
||||
/^192\.168\./,
|
||||
]
|
||||
// IPv6 literals arrive bracketed (e.g. "[::1]"), so the previous
|
||||
// string-equality checks never matched them.
|
||||
if (lowerHost.includes(":")) {
|
||||
return isPrivateIPv6(lowerHost)
|
||||
}
|
||||
|
||||
return privateIpPatterns.some((pattern) => pattern.test(hostname))
|
||||
// IPv4. The WHATWG URL parser canonicalizes alternate encodings
|
||||
// (decimal/hex/octal, e.g. http://2130706433 -> 127.0.0.1) to dotted-quad,
|
||||
// so matching the dotted form here also blocks those encodings.
|
||||
const octets = lowerHost.split(".")
|
||||
if (
|
||||
octets.length === 4 &&
|
||||
octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
|
||||
) {
|
||||
const [a, b] = octets.map(Number) as [number, number, number, number]
|
||||
return isPrivateIPv4Octets(a, b)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// File extensions that are not HTML and can't be scraped for OG data
|
||||
|
|
@ -179,61 +290,70 @@ export async function GET(request: Request) {
|
|||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000)
|
||||
|
||||
const response = await fetch(trimmedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
try {
|
||||
const response = await fetch(trimmedUrl, {
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location")
|
||||
if (location) {
|
||||
const redirectUrl = new URL(location, trimmedUrl).href
|
||||
if (
|
||||
!isValidUrl(redirectUrl) ||
|
||||
isPrivateHost(new URL(redirectUrl).hostname)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Invalid or private redirect URL" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// For simplicity, we only follow one level of redirect manually to re-validate IP
|
||||
const secondResponse = await fetch(redirectUrl, {
|
||||
signal: controller.signal,
|
||||
redirect: "error", // No more redirects allowed
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
if (!secondResponse.ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch redirect URL" },
|
||||
{ status: secondResponse.status },
|
||||
)
|
||||
}
|
||||
const contentType = secondResponse.headers.get("content-type")
|
||||
if (contentType && !contentType.includes("text/html")) {
|
||||
return Response.json({ title: "", description: "" })
|
||||
}
|
||||
const html = await secondResponse.text()
|
||||
return processHtml(html, redirectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch URL" },
|
||||
{ status: response.status },
|
||||
)
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch URL" },
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (contentType && !contentType.includes("text/html")) {
|
||||
return Response.json({ title: "", description: "" })
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
return processHtml(html, trimmedUrl)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
const titlePatterns = [
|
||||
/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i,
|
||||
/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i,
|
||||
/<title>([^<]+)<\/title>/i,
|
||||
]
|
||||
|
||||
const descriptionPatterns = [
|
||||
/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i,
|
||||
/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i,
|
||||
]
|
||||
|
||||
const imagePatterns = [
|
||||
/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i,
|
||||
/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i,
|
||||
]
|
||||
|
||||
const title = extractMetaTag(html, titlePatterns)
|
||||
const description = extractMetaTag(html, descriptionPatterns)
|
||||
const imageUrl = extractMetaTag(html, imagePatterns)
|
||||
const resolvedImageUrl = resolveImageUrl(imageUrl, trimmedUrl)
|
||||
|
||||
const ogResponse: OGResponse = {
|
||||
title,
|
||||
description,
|
||||
...(resolvedImageUrl && { image: resolvedImageUrl }),
|
||||
}
|
||||
|
||||
return Response.json(ogResponse, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return Response.json({ error: "Request timeout" }, { status: 504 })
|
||||
|
|
@ -242,3 +362,42 @@ export async function GET(request: Request) {
|
|||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function processHtml(html: string, baseUrl: string) {
|
||||
const titlePatterns = [
|
||||
/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i,
|
||||
/<meta\s+name=["']twitter:title["']\s+content=["']([^"']+)["']/i,
|
||||
/<title>([^<]+)<\/title>/i,
|
||||
]
|
||||
|
||||
const descriptionPatterns = [
|
||||
/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i,
|
||||
/<meta\s+name=["']twitter:description["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i,
|
||||
]
|
||||
|
||||
const imagePatterns = [
|
||||
/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i,
|
||||
/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i,
|
||||
/<meta\s+name=["']twitter:image["']\s+content=["']([^"']+)["']/i,
|
||||
]
|
||||
|
||||
const title = extractMetaTag(html, titlePatterns)
|
||||
const description = extractMetaTag(html, descriptionPatterns)
|
||||
const imageUrl = extractMetaTag(html, imagePatterns)
|
||||
const resolvedImageUrl = resolveImageUrl(imageUrl, baseUrl)
|
||||
|
||||
const ogResponse: OGResponse = {
|
||||
title,
|
||||
description,
|
||||
...(resolvedImageUrl && { image: resolvedImageUrl }),
|
||||
}
|
||||
|
||||
return Response.json(ogResponse, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,22 @@ 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) {
|
||||
|
|
@ -30,7 +44,7 @@ export async function POST(request: Request) {
|
|||
const response = await fetch("https://api.exa.ai/contents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": process.env.EXA_API_KEY ?? "",
|
||||
"x-api-key": exaApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -7,16 +7,44 @@ interface ResearchRequest {
|
|||
email?: string
|
||||
}
|
||||
|
||||
function extractHandle(url: string): string {
|
||||
const cleaned = url
|
||||
.toLowerCase()
|
||||
.replace("https://x.com/", "")
|
||||
.replace("https://twitter.com/", "")
|
||||
.replace("http://x.com/", "")
|
||||
.replace("http://twitter.com/", "")
|
||||
.replace("@", "")
|
||||
const ALLOWED_X_HOSTS: ReadonlySet<string> = new Set([
|
||||
"x.com",
|
||||
"www.x.com",
|
||||
"twitter.com",
|
||||
"www.twitter.com",
|
||||
"mobile.twitter.com",
|
||||
])
|
||||
|
||||
return (cleaned.split("/")[0] ?? cleaned).split("?")[0] ?? cleaned
|
||||
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) {
|
||||
|
|
@ -47,6 +75,13 @@ export async function POST(req: Request) {
|
|||
|
||||
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}`)
|
||||
|
|
|
|||
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../connect/page"
|
||||
|
|
@ -99,7 +99,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Captures coding decisions and patterns automatically",
|
||||
"Builds persistent user profile across projects",
|
||||
],
|
||||
icon: "/images/plugins/codex.svg",
|
||||
icon: "/images/plugins/codex.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +199,7 @@ function AuthConnectContent() {
|
|||
|
||||
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)
|
||||
|
|
@ -395,7 +396,7 @@ function AuthConnectContent() {
|
|||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/?view=plugins"
|
||||
href="https://app.supermemory.ai/settings#billing"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,34 @@ export const metadata: Metadata = {
|
|||
],
|
||||
},
|
||||
manifest: "/manifest.webmanifest",
|
||||
openGraph: {
|
||||
description: "Your memories, wherever you are",
|
||||
images: [
|
||||
{
|
||||
url: "/OG.png",
|
||||
width: 1800,
|
||||
height: 945,
|
||||
alt: "supermemory app",
|
||||
},
|
||||
],
|
||||
siteName: "supermemory",
|
||||
title: "supermemory app",
|
||||
type: "website",
|
||||
},
|
||||
title: "supermemory app",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
description: "Your memories, wherever you are",
|
||||
images: ["/OG.png"],
|
||||
title: "supermemory app",
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "resizes-content",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
703
apps/web/app/oauth/consent/ConsentCard.tsx
Normal file
703
apps/web/app/oauth/consent/ConsentCard.tsx
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
|
||||
import { Logo, LogoFull } from "@ui/assets/Logo"
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@ui/components/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip"
|
||||
import { ArrowLeft, BadgeCheck, Check, LoaderIcon, X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string }>
|
||||
|
||||
// Mirror of the OAuth plugins in mono's packages/lib/plugins.ts. An entry here
|
||||
// makes a client "verified" — we show its bundled icon + real name. Unknown DCR
|
||||
// clients still connect; they just render as a generic MCP client.
|
||||
export const OAUTH_PLUGINS: Record<
|
||||
string,
|
||||
{ name: string; icon?: IconComponent }
|
||||
> = {
|
||||
"supermemory-claude-code": { name: "Claude Code", icon: ClaudeDesktopIcon },
|
||||
"supermemory-opencode": { name: "OpenCode" },
|
||||
"supermemory-openclaw": { name: "OpenClaw" },
|
||||
"supermemory-codex": { name: "OpenAI Codex" },
|
||||
}
|
||||
|
||||
const GRADIENT_BG =
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)"
|
||||
const GRADIENT_SHADOW =
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)"
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ label: "1 year", value: "365" },
|
||||
{ label: "6 months", value: "180" },
|
||||
{ label: "90 days", value: "90" },
|
||||
{ label: "30 days", value: "30" },
|
||||
{ label: "7 days", value: "7" },
|
||||
{ label: "Never", value: "0" },
|
||||
]
|
||||
|
||||
export function shortClientId(id: string): string {
|
||||
return id.length > 12 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id
|
||||
}
|
||||
|
||||
export type ConsentOrg = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
export type ConsentPermission = "read" | "write"
|
||||
export type ConsentScopeType = "full" | "scoped"
|
||||
export type ConsentScope = {
|
||||
permission: ConsentPermission
|
||||
scopeType: ConsentScopeType
|
||||
tags: string[]
|
||||
expiresDays: number
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectingDots() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{["a", "b", "c"].map((k, i) => (
|
||||
<span
|
||||
className="size-1.5 animate-pulse rounded-full bg-[#525660]"
|
||||
key={k}
|
||||
style={{
|
||||
animationDelay: `${i * 220}ms`,
|
||||
animationDuration: "1100ms",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectingHeader({
|
||||
clientIcon: ClientIcon,
|
||||
}: {
|
||||
clientIcon: IconComponent
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<Logo className="h-6 w-auto text-white" />
|
||||
</div>
|
||||
<ConnectingDots />
|
||||
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
|
||||
<ClientIcon className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpacesPicker({
|
||||
options,
|
||||
selected,
|
||||
setSelected,
|
||||
loading,
|
||||
disabled,
|
||||
}: {
|
||||
options: string[]
|
||||
selected: string[]
|
||||
setSelected: (next: string[]) => void
|
||||
loading: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [open, setOpen] = useState(false)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return options
|
||||
.filter(
|
||||
(o) => !selected.includes(o) && (!q || o.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(0, 50)
|
||||
}, [options, selected, query])
|
||||
|
||||
const add = (t: string) => {
|
||||
if (!selected.includes(t)) setSelected([...selected, t])
|
||||
setQuery("")
|
||||
}
|
||||
const remove = (t: string) => setSelected(selected.filter((x) => x !== t))
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open && !disabled}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className="rounded-[12px] border border-white/[0.06] bg-[#0B0D11] p-2.5"
|
||||
ref={fieldRef}
|
||||
>
|
||||
{selected.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{selected.map((t) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-[7px] bg-[#1B1E25] py-1 pr-1.5 pl-2.5 text-[12.5px] text-[#FAFAFA]"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
<button
|
||||
className="text-[#9AA0A6] transition-colors hover:text-[#FAFAFA]"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(t)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="w-full bg-transparent px-1 py-1 text-[14px] text-[#FAFAFA] placeholder:text-[#5C5C5C] focus:outline-none"
|
||||
disabled={disabled}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={loading ? "Loading spaces…" : "Search spaces to add…"}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[210px] w-[var(--radix-popover-trigger-width)] overflow-y-auto rounded-[10px] border-white/10 bg-[#14161A] p-1 shadow-[0px_8px_28px_rgba(0,0,0,0.5)] [scrollbar-width:thin]"
|
||||
onFocusOutside={(e) => {
|
||||
if (fieldRef.current?.contains(e.target as Node)) e.preventDefault()
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (fieldRef.current?.contains(e.target as Node)) e.preventDefault()
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
sideOffset={6}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-2 text-[12.5px] text-[#737373]">
|
||||
{loading
|
||||
? "Loading spaces…"
|
||||
: query.trim()
|
||||
? `No spaces match “${query.trim()}”.`
|
||||
: "No spaces available."}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<button
|
||||
className="flex w-full items-center rounded-[7px] px-2 py-1.5 text-left text-[13px] text-[#E8E8E8] transition-colors hover:bg-white/[0.05]"
|
||||
key={t}
|
||||
onClick={() => add(t)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.06), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex max-h-[92vh] w-full max-w-[460px] flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FullScreenMessage({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
}) {
|
||||
return (
|
||||
<CardShell>
|
||||
<div className="p-6 text-center">
|
||||
<LogoFull className="mx-auto h-5 w-auto text-[#FAFAFA]" />
|
||||
<h2 className="mt-5 text-[18px] font-semibold text-[#FAFAFA]">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] text-[#737373]">{subtitle}</p>
|
||||
</div>
|
||||
</CardShell>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ConsentCardProps {
|
||||
appLabel: string
|
||||
verified: boolean
|
||||
clientId: string
|
||||
userEmail?: string
|
||||
orgs: ConsentOrg[]
|
||||
availableTags: string[]
|
||||
tagsLoading: boolean
|
||||
submitting: "approve" | "deny" | null
|
||||
error: string | null
|
||||
onEnterOrg: (orgId: string) => Promise<void>
|
||||
onScopedOpen: () => void
|
||||
onSubmit: (accept: boolean, scope: ConsentScope) => void
|
||||
onSignOut: () => void
|
||||
}
|
||||
|
||||
export function ConsentCard({
|
||||
appLabel,
|
||||
verified,
|
||||
clientId,
|
||||
userEmail,
|
||||
orgs,
|
||||
availableTags,
|
||||
tagsLoading,
|
||||
submitting,
|
||||
error,
|
||||
onEnterOrg,
|
||||
onScopedOpen,
|
||||
onSubmit,
|
||||
onSignOut,
|
||||
}: ConsentCardProps) {
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const [autoTried, setAutoTried] = useState(false)
|
||||
const [permission, setPermission] = useState<ConsentPermission>("write")
|
||||
const [scopeType, setScopeType] = useState<ConsentScopeType>("full")
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [expiresDays, setExpiresDays] = useState("365")
|
||||
|
||||
const multiOrg = orgs.length > 1
|
||||
const busy = submitting !== null || switchingOrgId !== null
|
||||
const selectedOrg = orgs.find((o) => o.id === selectedOrgId) ?? null
|
||||
const ClientIcon = (clientId && OAUTH_PLUGINS[clientId]?.icon) || MCPIcon
|
||||
const title = verified ? `Authorize ${appLabel}` : "Authorize MCP"
|
||||
const connectLabel = verified ? appLabel : "this MCP client"
|
||||
|
||||
const enterOrg = useCallback(
|
||||
async (orgId: string) => {
|
||||
if (!orgId) return
|
||||
setSelectedOrgId(orgId)
|
||||
setSwitchingOrgId(orgId)
|
||||
try {
|
||||
await onEnterOrg(orgId)
|
||||
} catch {
|
||||
setSwitchingOrgId(null)
|
||||
return
|
||||
}
|
||||
setSwitchingOrgId(null)
|
||||
setTags([])
|
||||
setStep(2)
|
||||
},
|
||||
[onEnterOrg],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoTried || step !== 1 || orgs.length !== 1) return
|
||||
setAutoTried(true)
|
||||
void enterOrg(orgs[0]?.id ?? "")
|
||||
}, [orgs, step, autoTried, enterOrg])
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 2 && scopeType === "scoped") onScopedOpen()
|
||||
}, [step, scopeType, onScopedOpen])
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollUp, setCanScrollUp] = useState(false)
|
||||
const [canScrollDown, setCanScrollDown] = useState(false)
|
||||
const measureFades = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el) return
|
||||
setCanScrollUp(el.scrollTop > 8)
|
||||
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 8)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (step !== 1 || orgs.length === 0) {
|
||||
setCanScrollUp(false)
|
||||
setCanScrollDown(false)
|
||||
return
|
||||
}
|
||||
measureFades(listRef.current)
|
||||
}, [orgs, step, measureFades])
|
||||
|
||||
const submit = (accept: boolean) =>
|
||||
onSubmit(accept, {
|
||||
permission,
|
||||
scopeType,
|
||||
tags,
|
||||
expiresDays: Number(expiresDays),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-[#08090C] p-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full max-w-[440px] flex-col",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="step1"
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div className="pt-2 pb-6 text-center">
|
||||
<Logo className="mx-auto h-8 w-auto text-white" />
|
||||
<h1 className="mt-6 text-[20px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
|
||||
Select an organization
|
||||
</h1>
|
||||
<p className="mt-3 text-[13px] text-[#737373]">
|
||||
Choose which organization to connect {connectLabel} to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex max-h-[360px] flex-col gap-2 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
onScroll={(e) => measureFades(e.currentTarget)}
|
||||
ref={listRef}
|
||||
>
|
||||
{orgs.length === 0 ? (
|
||||
<p className="py-6 text-center text-[13px] text-[#737373]">
|
||||
No organizations found on your account.
|
||||
</p>
|
||||
) : (
|
||||
orgs.map((o) => {
|
||||
const switching = switchingOrgId === o.id
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-3.5 text-left transition-colors",
|
||||
"hover:bg-[#1B1E25]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
disabled={busy}
|
||||
key={o.id}
|
||||
onClick={() => enterOrg(o.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-white/[0.06] text-[14px] font-semibold text-[#FAFAFA]">
|
||||
{o.name?.charAt(0)?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-[#FAFAFA]">
|
||||
{o.name}
|
||||
</span>
|
||||
{switching && (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#9AA0A6]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-[#08090C] to-transparent transition-opacity duration-300",
|
||||
canScrollUp ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-[#08090C] to-transparent transition-opacity duration-300",
|
||||
canScrollDown ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="pt-3 text-center text-[13px] text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-1 pt-6">
|
||||
{userEmail && (
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
Signed in as {userEmail}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className="text-[12px] text-[#9AA0A6] transition-colors hover:text-[#FAFAFA]"
|
||||
onClick={onSignOut}
|
||||
type="button"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex max-h-[92vh] min-h-0 flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="step2"
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div className="px-6 pt-7 pb-5 text-center">
|
||||
<ConnectingHeader clientIcon={ClientIcon} />
|
||||
<div className="mt-5 flex items-center justify-center gap-1.5">
|
||||
<h1 className="text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
|
||||
{title}
|
||||
</h1>
|
||||
{verified && (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-[#FAFAFA]">
|
||||
<BadgeCheck className="size-[18px]" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Verified app</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-[#737373]">
|
||||
{verified ? appLabel : "An MCP client"} wants to connect to
|
||||
your supermemory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-3.5">
|
||||
<div className="min-w-0">
|
||||
<SectionLabel>Connecting to</SectionLabel>
|
||||
<p className="truncate text-[14px] font-medium text-[#FAFAFA]">
|
||||
{selectedOrg?.name ?? "Workspace"}
|
||||
</p>
|
||||
</div>
|
||||
{multiOrg && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded-[7px] px-2 py-1.5 text-[12px] text-[#9AA0A6] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => setStep(1)}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
|
||||
<div className="flex flex-col gap-4 px-6 py-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Permission</SectionLabel>
|
||||
<Choice
|
||||
disabled={busy}
|
||||
onChange={setPermission}
|
||||
options={[
|
||||
{ value: "write", label: "Read + Write" },
|
||||
{ value: "read", label: "Read only" },
|
||||
]}
|
||||
value={permission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Access</SectionLabel>
|
||||
<Choice
|
||||
disabled={busy}
|
||||
onChange={setScopeType}
|
||||
options={[
|
||||
{ value: "full", label: "Full access" },
|
||||
{ value: "scoped", label: "Scoped" },
|
||||
]}
|
||||
value={scopeType}
|
||||
/>
|
||||
<AnimatePresence initial={false}>
|
||||
{scopeType === "scoped" && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="overflow-hidden"
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="mt-1.5">
|
||||
<SpacesPicker
|
||||
disabled={busy}
|
||||
loading={tagsLoading}
|
||||
options={availableTags}
|
||||
selected={tags}
|
||||
setSelected={setTags}
|
||||
/>
|
||||
<p className="mt-2 text-[12px] text-[#5C6470]">
|
||||
{tags.length > 0
|
||||
? `${tags.length} space${tags.length === 1 ? "" : "s"} selected`
|
||||
: "Type to search and add spaces."}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Expires</SectionLabel>
|
||||
<Select
|
||||
disabled={busy}
|
||||
onValueChange={setExpiresDays}
|
||||
value={expiresDays}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-full rounded-[10px] border-white/[0.08] bg-[#0B0D11] text-[14px] text-[#FAFAFA]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[13px] text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 h-px bg-white/[0.06]" />
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4">
|
||||
<button
|
||||
className="rounded-[10px] px-3 py-2.5 text-[13px] font-medium text-[#9AA0A6] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => submit(false)}
|
||||
type="button"
|
||||
>
|
||||
{submitting === "deny" ? "Cancelling…" : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-11 min-w-[150px] items-center justify-center gap-2 rounded-[10px] px-6",
|
||||
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
)}
|
||||
disabled={
|
||||
busy || (scopeType === "scoped" && tags.length === 0)
|
||||
}
|
||||
onClick={() => submit(true)}
|
||||
style={{
|
||||
background: GRADIENT_BG,
|
||||
boxShadow: GRADIENT_SHADOW,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{submitting === "approve" ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Check className="size-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{clientId && !verified && (
|
||||
<p className="px-6 pb-4 text-center text-[11px] text-[#5C5C5C]">
|
||||
App ID · <code>{shortClientId(clientId)}</code>
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Choice<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
options: { value: T; label: string }[]
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-2", dmSans125ClassName())}>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"h-10 rounded-[10px] border px-3 text-[13px] font-medium transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
? "border-white/15 bg-white/[0.07] text-[#FAFAFA]"
|
||||
: "cursor-pointer border-white/[0.07] bg-transparent text-[#9AA0A6] hover:border-white/15 hover:text-[#FAFAFA]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
type="button"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
253
apps/web/app/oauth/consent/page.tsx
Normal file
253
apps/web/app/oauth/consent/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client"
|
||||
|
||||
import { authClient, useSession } from "@lib/auth"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense, useCallback, useMemo, useState } from "react"
|
||||
import {
|
||||
CardShell,
|
||||
ConsentCard,
|
||||
type ConsentScope,
|
||||
FullScreenMessage,
|
||||
OAUTH_PLUGINS,
|
||||
} from "./ConsentCard"
|
||||
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
function OAuthConsentContent() {
|
||||
const params = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const { data: organizations } = authClient.useListOrganizations()
|
||||
|
||||
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null)
|
||||
const [done, setDone] = useState<"approved" | "denied" | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([])
|
||||
const [tagsLoading, setTagsLoading] = useState(false)
|
||||
|
||||
const orgs = useMemo(
|
||||
() => (organizations ?? []).map((o) => ({ id: o.id, name: o.name })),
|
||||
[organizations],
|
||||
)
|
||||
const activeOrgId = session?.session.activeOrganizationId ?? null
|
||||
const clientId = params.get("client_id") ?? ""
|
||||
const plugin = clientId ? (OAUTH_PLUGINS[clientId] ?? null) : null
|
||||
const appLabel = plugin?.name ?? "An application"
|
||||
|
||||
// A valid consent page is reached only via /oauth2/authorize, which appends a
|
||||
// signed (`sig`) + short-lived (`exp`) query. Without that it can't succeed.
|
||||
const expSeconds = Number(params.get("exp"))
|
||||
const requestExpired = expSeconds > 0 && expSeconds * 1000 < Date.now()
|
||||
const invalidRequest = !params.get("sig") || requestExpired
|
||||
|
||||
const onEnterOrg = useCallback(
|
||||
async (orgId: string) => {
|
||||
setError(null)
|
||||
if (orgId !== activeOrgId) {
|
||||
try {
|
||||
await authClient.organization.setActive({ organizationId: orgId })
|
||||
} catch (err) {
|
||||
setError("Couldn't switch to that organization. Try again.")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
setAvailableTags([])
|
||||
},
|
||||
[activeOrgId],
|
||||
)
|
||||
|
||||
const onScopedOpen = useCallback(() => {
|
||||
if (tagsLoading || availableTags.length > 0) return
|
||||
setTagsLoading(true)
|
||||
fetch(`${API_URL}/v3/container-tags/list`, { credentials: "include" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
const list = (d?.containerTags ?? d?.tags ?? d ?? []) as unknown[]
|
||||
const names = (Array.isArray(list) ? list : [])
|
||||
.map((t) =>
|
||||
typeof t === "string"
|
||||
? t
|
||||
: ((t as { containerTag?: string; tag?: string })?.containerTag ??
|
||||
(t as { tag?: string })?.tag ??
|
||||
null),
|
||||
)
|
||||
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
setAvailableTags(Array.from(new Set(names)))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setTagsLoading(false))
|
||||
}, [tagsLoading, availableTags.length])
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (accept: boolean, scope: ConsentScope) => {
|
||||
// Send the raw, unmodified query string — better-auth re-verifies its HMAC,
|
||||
// so it must be byte-for-byte what we were redirected with.
|
||||
const oauthQuery = window.location.search.replace(/^\?/, "")
|
||||
if (!oauthQuery) {
|
||||
setError(
|
||||
"Missing authorization request. Start the flow again from your app.",
|
||||
)
|
||||
return
|
||||
}
|
||||
setSubmitting(accept ? "approve" : "deny")
|
||||
setError(null)
|
||||
try {
|
||||
if (accept && clientId) {
|
||||
const scopeRes = await fetch(`${API_URL}/v3/mcp/connect-scope`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
permission: scope.permission,
|
||||
containerTags: scope.scopeType === "scoped" ? scope.tags : [],
|
||||
expiresDays: scope.expiresDays,
|
||||
}),
|
||||
})
|
||||
if (!scopeRes.ok) {
|
||||
const scopeData = (await scopeRes.json().catch(() => ({}))) as {
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
throw new Error(
|
||||
scopeData.message ||
|
||||
scopeData.error ||
|
||||
"Could not save MCP access settings. Start the connection again.",
|
||||
)
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${API_URL}/api/auth/oauth2/consent`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ accept, oauth_query: oauthQuery }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
url?: string
|
||||
redirectURI?: string
|
||||
redirect_uri?: string
|
||||
message?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
if (
|
||||
data.error === "invalid_signature" ||
|
||||
data.error === "invalid_request"
|
||||
) {
|
||||
throw new Error(
|
||||
"This authorization request has expired. Start the connection again from your app.",
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
data.error_description ||
|
||||
data.message ||
|
||||
data.error ||
|
||||
"Authorization failed.",
|
||||
)
|
||||
}
|
||||
// Many clients use a loopback or custom-scheme redirect URI that hands
|
||||
// off without replacing this tab, but the server still has to provide it.
|
||||
const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri
|
||||
if (!redirectUrl) {
|
||||
throw new Error(
|
||||
"Authorization completed but no redirect URL was returned. Start the connection again from your app.",
|
||||
)
|
||||
}
|
||||
const targetUrl = new URL(redirectUrl, window.location.href)
|
||||
const isSignedInteractionRedirect =
|
||||
targetUrl.origin === window.location.origin &&
|
||||
(targetUrl.pathname === "/login" ||
|
||||
targetUrl.pathname === "/oauth/consent") &&
|
||||
targetUrl.searchParams.has("sig") &&
|
||||
targetUrl.searchParams.has("exp")
|
||||
console.log("isSignedInteractionRedirect", isSignedInteractionRedirect)
|
||||
console.log("targetUrl", targetUrl)
|
||||
console.log("accept", accept)
|
||||
console.log("window.location.origin", window.location.origin)
|
||||
console.log("pathname", targetUrl.pathname)
|
||||
if (accept && isSignedInteractionRedirect) {
|
||||
throw new Error(
|
||||
"Authorization could not finish because your session changed. Start the connection again from your app.",
|
||||
)
|
||||
}
|
||||
setDone(accept ? "approved" : "denied")
|
||||
if (redirectUrl) window.location.href = redirectUrl
|
||||
} catch (err) {
|
||||
console.error("OAuth consent failed:", err)
|
||||
setError(err instanceof Error ? err.message : "Authorization failed.")
|
||||
setSubmitting(null)
|
||||
}
|
||||
},
|
||||
[clientId],
|
||||
)
|
||||
|
||||
const onSignOut = useCallback(async () => {
|
||||
try {
|
||||
await authClient.signOut()
|
||||
} catch {}
|
||||
window.location.href = "/login"
|
||||
}, [])
|
||||
|
||||
if (invalidRequest && !done) {
|
||||
return (
|
||||
<FullScreenMessage
|
||||
subtitle="Start the connection again from your app — this page only works as part of that flow."
|
||||
title={
|
||||
requestExpired
|
||||
? "This request has expired"
|
||||
: "No authorization request"
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<FullScreenMessage
|
||||
subtitle="You can return to your app — it's safe to close this tab."
|
||||
title={done === "approved" ? "Access authorized" : "Access denied"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConsentCard
|
||||
appLabel={appLabel}
|
||||
availableTags={availableTags}
|
||||
clientId={clientId}
|
||||
error={error}
|
||||
onEnterOrg={onEnterOrg}
|
||||
onScopedOpen={onScopedOpen}
|
||||
onSignOut={onSignOut}
|
||||
onSubmit={onSubmit}
|
||||
orgs={orgs}
|
||||
submitting={submitting}
|
||||
tagsLoading={tagsLoading}
|
||||
userEmail={session?.user?.email}
|
||||
verified={!!plugin}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OAuthConsentPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<CardShell>
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-[#4BA0FA] border-t-transparent" />
|
||||
</div>
|
||||
</CardShell>
|
||||
}
|
||||
>
|
||||
<OAuthConsentContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal file
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"use client"
|
||||
|
||||
import { authClient, useSession } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Loader, Users, XCircle } from "lucide-react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
type InvitationData = {
|
||||
id: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
expiresAt: string
|
||||
organizationName: string
|
||||
organizationSlug: string
|
||||
organizationId: string
|
||||
inviterEmail?: string
|
||||
}
|
||||
|
||||
type InviteState =
|
||||
| "loading"
|
||||
| "no_session"
|
||||
| "ready"
|
||||
| "not_found"
|
||||
| "expired"
|
||||
| "already_accepted"
|
||||
| "wrong_account"
|
||||
|
||||
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 FullPageSpinner() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
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]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconTile({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Title({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2
|
||||
className={dmSans125ClassName("font-semibold text-[18px] text-[#FAFAFA]")}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function Subtitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className={dmSans125ClassName("text-[13px] text-[#737373] mt-1")}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
onAction: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>{icon}</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>{title}</Title>
|
||||
<Subtitle>{description}</Subtitle>
|
||||
</div>
|
||||
<SecondaryButton onClick={onAction}>{actionLabel}</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function InvitePage() {
|
||||
const params = useParams<{ invitationId: string }>()
|
||||
const invitationId = params.invitationId
|
||||
const { data: session, isPending: sessionPending } = useSession()
|
||||
const { setActiveOrg, refetchOrganizations } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const [state, setState] = useState<InviteState>("loading")
|
||||
const [invitation, setInvitation] = useState<InvitationData | null>(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [declining, setDeclining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionPending) return
|
||||
if (!session) {
|
||||
setState("no_session")
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const { data, error } = await authClient.organization.getInvitation({
|
||||
query: { id: invitationId },
|
||||
})
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
setState(error.status === 403 ? "wrong_account" : "not_found")
|
||||
return
|
||||
}
|
||||
if (!data) {
|
||||
setState("not_found")
|
||||
return
|
||||
}
|
||||
const inv = data as unknown as InvitationData
|
||||
if (inv.status === "accepted") setState("already_accepted")
|
||||
else if (inv.status === "canceled" || inv.status === "rejected")
|
||||
setState("not_found")
|
||||
else if (new Date(inv.expiresAt) < new Date()) setState("expired")
|
||||
else {
|
||||
setInvitation(inv)
|
||||
setState("ready")
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [session, sessionPending, invitationId])
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setAccepting(true)
|
||||
try {
|
||||
const { error } = await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
})
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to accept invitation")
|
||||
return
|
||||
}
|
||||
if (invitation?.organizationSlug) {
|
||||
await setActiveOrg(invitation.organizationSlug)
|
||||
}
|
||||
await refetchOrganizations()
|
||||
toast.success(
|
||||
`You've joined ${invitation?.organizationName ?? "the team"}`,
|
||||
)
|
||||
router.push("/")
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}, [invitationId, invitation, setActiveOrg, refetchOrganizations, router])
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
setDeclining(true)
|
||||
try {
|
||||
const { error } = await authClient.organization.rejectInvitation({
|
||||
invitationId,
|
||||
})
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to decline invitation")
|
||||
return
|
||||
}
|
||||
toast.success("Invitation declined")
|
||||
router.push("/")
|
||||
} finally {
|
||||
setDeclining(false)
|
||||
}
|
||||
}, [invitationId, router])
|
||||
|
||||
if (state === "loading") return <FullPageSpinner />
|
||||
|
||||
if (state === "no_session") {
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(
|
||||
typeof window !== "undefined" ? window.location.href : "",
|
||||
)}`
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>
|
||||
<Users className="size-5 text-[#4BA0FA]" />
|
||||
</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>You're not logged in</Title>
|
||||
<Subtitle>Log in to view and accept this invitation.</Subtitle>
|
||||
</div>
|
||||
<PrimaryButton onClick={() => router.push(loginHref)}>
|
||||
Log in
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "wrong_account") {
|
||||
return (
|
||||
<StatusCard
|
||||
icon={<XCircle className="size-5 text-red-400" />}
|
||||
title="This invitation isn't for you"
|
||||
description={`It was sent to a different email${
|
||||
session?.user?.email ? ` than ${session.user.email}` : ""
|
||||
}.`}
|
||||
actionLabel="Go to dashboard"
|
||||
onAction={() => router.push("/")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
state === "not_found" ||
|
||||
state === "expired" ||
|
||||
state === "already_accepted"
|
||||
) {
|
||||
const copy = {
|
||||
not_found: {
|
||||
title: "Invitation not found",
|
||||
body: "This invitation doesn't exist or has been revoked.",
|
||||
},
|
||||
expired: {
|
||||
title: "Invitation expired",
|
||||
body: "Ask your team admin to send a new one.",
|
||||
},
|
||||
already_accepted: {
|
||||
title: "Already joined",
|
||||
body: "You've already accepted this invitation.",
|
||||
},
|
||||
}[state]
|
||||
return (
|
||||
<StatusCard
|
||||
icon={<Users className="size-5 text-[#4BA0FA]" />}
|
||||
title={copy.title}
|
||||
description={copy.body}
|
||||
actionLabel="Go to dashboard"
|
||||
onAction={() => router.push("/")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<IconTile>
|
||||
<Users className="size-5 text-[#4BA0FA]" />
|
||||
</IconTile>
|
||||
<div className="text-center">
|
||||
<Title>{invitation?.organizationName}</Title>
|
||||
<Subtitle>
|
||||
You've been invited to join{" "}
|
||||
<strong className="text-[#A3A3A3]">
|
||||
{invitation?.organizationName}
|
||||
</strong>{" "}
|
||||
as {invitation?.role}.
|
||||
</Subtitle>
|
||||
{invitation?.inviterEmail && (
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] mt-2",
|
||||
)}
|
||||
>
|
||||
Invited by {invitation.inviterEmail}
|
||||
</p>
|
||||
)}
|
||||
{session?.user?.email && (
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
Signed in as {session.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2.5">
|
||||
<PrimaryButton
|
||||
onClick={handleAccept}
|
||||
disabled={accepting || declining}
|
||||
>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin mr-2" />
|
||||
Accepting…
|
||||
</>
|
||||
) : (
|
||||
"Accept invitation"
|
||||
)}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
onClick={handleDecline}
|
||||
disabled={declining || accepting}
|
||||
>
|
||||
{declining ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Declining…
|
||||
</>
|
||||
) : (
|
||||
"Decline"
|
||||
)}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion, OneDrive, Zoom } from "@ui/assets/icons"
|
||||
import { GoogleDrive, Granola, Notion, OneDrive, Zoom } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -31,12 +31,16 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { GranolaConnectModal } from "@/components/granola-connect-modal"
|
||||
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
||||
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
|
||||
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
|
||||
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
||||
import { useTriggerSync } from "@/hooks/use-trigger-sync"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getConnectionSubtitle,
|
||||
} from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
|
||||
type GDriveSyncScope = "scoped" | "full"
|
||||
|
|
@ -48,7 +52,7 @@ const GDRIVE_SCOPE_LABELS: Record<GDriveSyncScope, string> = {
|
|||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "zoom"
|
||||
type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "zoom" | "granola"
|
||||
|
||||
const CONNECTORS: Record<
|
||||
ConnectorProvider,
|
||||
|
|
@ -83,6 +87,12 @@ const CONNECTORS: Record<
|
|||
documentLabel: "meetings",
|
||||
icon: Zoom,
|
||||
},
|
||||
granola: {
|
||||
title: "Granola",
|
||||
description: "Sync AI meeting notes and transcripts",
|
||||
documentLabel: "notes",
|
||||
icon: Granola,
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Extract typed metadata from a connection, with runtime validation. */
|
||||
|
|
@ -168,7 +178,7 @@ function ConnectionRow({
|
|||
"truncate text-[14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connection.email || "Unknown"}
|
||||
{getConnectionSubtitle(connection)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
|
|
@ -305,9 +315,10 @@ interface ConnectContentProps {
|
|||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const { connectorAccess } = useConnectorAccess()
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
||||
const [gdriveSyncScope, setGdriveSyncScope] =
|
||||
useState<GDriveSyncScope>("scoped")
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
|
|
@ -320,11 +331,11 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const handleUpgrade = async (planId: "api_pro" | "api_max" = "api_pro") => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
planId,
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
|
|
@ -393,7 +404,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
provider: ConnectorProvider
|
||||
syncScope?: GDriveSyncScope
|
||||
}) => {
|
||||
if (!canAddConnection && !isProUser) {
|
||||
if (!canAddConnection && !connectorAccess) {
|
||||
throw new Error(
|
||||
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
|
||||
)
|
||||
|
|
@ -552,7 +563,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
type="button"
|
||||
onClick={() => handleConnect("google-drive")}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
!connectorAccess ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
|
|
@ -598,13 +609,38 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : provider === "granola" ? (
|
||||
<>
|
||||
{!connectorAccess && (
|
||||
<span className="bg-[#0054AD] text-[#FAFAFA] text-[10px] font-bold px-1.5 py-[2px] rounded-[3px] uppercase tracking-wide">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
setGranolaModalOpen(true)
|
||||
}}
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
{!connectorAccess
|
||||
? isUpgrading || autumn.isLoading
|
||||
? "Upgrading..."
|
||||
: "Upgrade"
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleConnect(provider as ConnectorProvider)
|
||||
}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
!connectorAccess ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
|
|
@ -651,7 +687,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isProUser || isAnyConnecting}
|
||||
disabled={!connectorAccess || isAnyConnecting}
|
||||
className="flex shrink-0 items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors"
|
||||
>
|
||||
{isAnyConnecting ? (
|
||||
|
|
@ -776,6 +812,34 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
onClick={() => {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
setGranolaModalOpen(true)
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100 data-disabled:opacity-40 data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent"
|
||||
>
|
||||
<Granola className="size-5 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="flex items-center gap-1.5 text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
Granola
|
||||
{!connectorAccess && (
|
||||
<span className="bg-[#0054AD] text-[#FAFAFA] text-[9px] font-bold px-1 py-px rounded-[3px] uppercase tracking-wide">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
{connectorAccess
|
||||
? "Meeting notes & transcripts"
|
||||
: "Upgrade to Pro"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -825,7 +889,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
|
||||
>
|
||||
<Zap className="size-6 text-[#737373] mb-3" />
|
||||
{!isProUser ? (
|
||||
{!connectorAccess ? (
|
||||
<>
|
||||
<p className="text-[14px] text-[#737373] mb-4 text-center">
|
||||
{isUpgrading || autumn.isLoading ? (
|
||||
|
|
@ -837,7 +901,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
onClick={() => handleUpgrade()}
|
||||
className="underline text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Upgrade to Pro
|
||||
|
|
@ -902,6 +966,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
}}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
/>
|
||||
|
||||
<GranolaConnectModal
|
||||
open={connectorAccess && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && connectorAccess)}
|
||||
containerTags={[selectedProject]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
|
|||
import { toast } from "sonner"
|
||||
|
||||
export const FILE_ACCEPT =
|
||||
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
|
||||
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,.html,.htm,text/markdown,application/json,text/html"
|
||||
|
||||
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
|
||||
|
||||
|
|
@ -46,10 +46,15 @@ function isAcceptedFile(file: File): boolean {
|
|||
".txt",
|
||||
".md",
|
||||
".mdx",
|
||||
".json",
|
||||
".html",
|
||||
".htm",
|
||||
])
|
||||
if (allowedExt.has(ext)) return true
|
||||
if (file.type.startsWith("image/")) return true
|
||||
if (file.type === "text/markdown") return true
|
||||
if (file.type === "application/json") return true
|
||||
if (file.type === "text/html") return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +62,18 @@ function fileQueueKey(file: File): string {
|
|||
return `${file.name}:${file.size}:${file.lastModified}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ["KB", "MB", "GB", "TB"]
|
||||
let size = bytes / 1024
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function FileContent({
|
||||
data,
|
||||
onDataChange,
|
||||
|
|
@ -194,9 +211,12 @@ export function FileContent({
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[16px] font-medium pl-2">
|
||||
Upload files (images, PDF, documents, sheets, markdown)
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
<p className="text-[16px] font-medium">Upload files</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
Images, PDF, documents, sheets, markdown, HTML
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -281,7 +301,7 @@ export function FileContent({
|
|||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
{item.status === "error" && item.errorMessage ? (
|
||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ const tabs = [
|
|||
id: "connect" as const,
|
||||
icon: ZapIcon,
|
||||
title: "Connect knowledge bases",
|
||||
compactLabel: "Connections",
|
||||
compactLabel: "Connect",
|
||||
description: "Sync with Google Drive, Notion and OneDrive and import data",
|
||||
isPro: true,
|
||||
},
|
||||
|
|
@ -147,9 +147,10 @@ export function AddDocument({
|
|||
const fileDataRef = useRef(fileData)
|
||||
fileDataRef.current = fileData
|
||||
|
||||
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
|
||||
onClose,
|
||||
})
|
||||
const { noteMutation, linkMutation, bulkLinkMutation, fileMutation } =
|
||||
useDocumentMutations({
|
||||
onClose,
|
||||
})
|
||||
|
||||
const autumn = useCustomer()
|
||||
const {
|
||||
|
|
@ -187,13 +188,30 @@ export function AddDocument({
|
|||
|
||||
const handleLinkSubmit = useCallback(
|
||||
(data: LinkData) => {
|
||||
// In bulk mode the selection is the source of truth, not the raw textarea.
|
||||
if (data.bulkUrls) {
|
||||
if (data.bulkUrls.length >= 2) {
|
||||
bulkLinkMutation.mutate({
|
||||
urls: data.bulkUrls,
|
||||
project: localSelectedProject,
|
||||
})
|
||||
return
|
||||
}
|
||||
const [onlyUrl] = data.bulkUrls
|
||||
if (onlyUrl) {
|
||||
linkMutation.mutate({ url: onlyUrl, project: localSelectedProject })
|
||||
return
|
||||
}
|
||||
toast.error("Select at least one link")
|
||||
return
|
||||
}
|
||||
if (!data.url.trim()) {
|
||||
toast.error("Please enter a URL")
|
||||
return
|
||||
}
|
||||
linkMutation.mutate({ url: data.url, project: localSelectedProject })
|
||||
},
|
||||
[linkMutation, localSelectedProject],
|
||||
[linkMutation, bulkLinkMutation, localSelectedProject],
|
||||
)
|
||||
|
||||
const handleFileSubmit = useCallback(
|
||||
|
|
@ -263,6 +281,14 @@ export function AddDocument({
|
|||
setFileData(data)
|
||||
}, [])
|
||||
|
||||
const handleNoteImportLinks = useCallback(
|
||||
(urls: string[]) => {
|
||||
setLinkData({ url: urls.join("\n"), title: "", description: "" })
|
||||
setActiveTab("link")
|
||||
},
|
||||
[setActiveTab],
|
||||
)
|
||||
|
||||
const handleButtonClick = () => {
|
||||
switch (activeTab) {
|
||||
case "note":
|
||||
|
|
@ -278,12 +304,33 @@ export function AddDocument({
|
|||
}
|
||||
|
||||
const isSubmitting =
|
||||
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
|
||||
noteMutation.isPending ||
|
||||
linkMutation.isPending ||
|
||||
bulkLinkMutation.isPending ||
|
||||
fileMutation.isPending
|
||||
|
||||
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
|
||||
const fileTabSubmitDisabled =
|
||||
activeTab === "file" && (!fileTabHasPending || isSubmitting)
|
||||
|
||||
const linkBulkCount = linkData.bulkUrls?.length ?? 0
|
||||
const linkTabSubmitDisabled =
|
||||
activeTab === "link" &&
|
||||
linkData.bulkUrls !== undefined &&
|
||||
linkBulkCount === 0
|
||||
|
||||
const spaceSelector = (
|
||||
<SpaceSelector
|
||||
selectedProjects={[localSelectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
compact={isMobile}
|
||||
triggerClassName={cn(isMobile && "h-12 shrink-0")}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
|
||||
{isMobile && !hasPaidPlan && (
|
||||
|
|
@ -445,11 +492,29 @@ export function AddDocument({
|
|||
isMobile ? "w-full px-4 pt-1" : "w-2/3 px-1",
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="mb-3 flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
active={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
icon={tab.icon}
|
||||
title={tab.title}
|
||||
compactLabel={tab.compactLabel}
|
||||
description={tab.description}
|
||||
isPro={tab.isPro}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
|
||||
{activeTab === "note" && (
|
||||
<NoteContent
|
||||
onSubmit={handleNoteSubmit}
|
||||
onContentChange={handleNoteContentChange}
|
||||
onImportLinks={handleNoteImportLinks}
|
||||
isSubmitting={noteMutation.isPending}
|
||||
isOpen={isOpen}
|
||||
initialContent={noteContent}
|
||||
|
|
@ -459,7 +524,9 @@ export function AddDocument({
|
|||
<LinkContent
|
||||
onSubmit={handleLinkSubmit}
|
||||
onDataChange={handleLinkDataChange}
|
||||
isSubmitting={linkMutation.isPending}
|
||||
isSubmitting={
|
||||
linkMutation.isPending || bulkLinkMutation.isPending
|
||||
}
|
||||
isOpen={isOpen}
|
||||
initialData={linkData}
|
||||
/>
|
||||
|
|
@ -487,38 +554,14 @@ export function AddDocument({
|
|||
: "justify-between gap-2 pt-3",
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
<div className="flex h-10 w-full shrink-0 items-center overflow-hidden rounded-full border border-[#1F2937] bg-[#0D121A] p-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
active={activeTab === tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
icon={tab.icon}
|
||||
title={tab.title}
|
||||
compactLabel={tab.compactLabel}
|
||||
description={tab.description}
|
||||
isPro={tab.isPro}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
selectedProjects={[localSelectedProject]}
|
||||
onValueChange={(projects) =>
|
||||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
/>
|
||||
)}
|
||||
{!isMobile && spaceSelector}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isMobile ? "w-full" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{isMobile && spaceSelector}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -534,15 +577,19 @@ export function AddDocument({
|
|||
variant="insideOut"
|
||||
onClick={handleButtonClick}
|
||||
disabled={
|
||||
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
|
||||
activeTab === "file"
|
||||
? fileTabSubmitDisabled
|
||||
: isSubmitting || linkTabSubmitDisabled
|
||||
}
|
||||
className={cn(isMobile && "h-12 w-full px-5 text-[15px]")}
|
||||
className={cn(isMobile && "h-12 flex-1 px-5 text-[15px]")}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Adding…
|
||||
</>
|
||||
) : activeTab === "link" && linkBulkCount >= 2 ? (
|
||||
`+ Add ${linkBulkCount} memories`
|
||||
) : (
|
||||
<>
|
||||
+ Add {activeTab}{" "}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue