mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-07-30 11:24:08 +00:00
Merge dalat-v1 into mcp-revamp-followup; keep redesigned widget files
Resolves the PR #1189 conflict against its base. Both branches edited apps/mcp/src/widget/{components/WorkspaceCard.tsx,design/tokens.css}; dalat-v1's dark-mode-readability tweak is superseded by the full widget redesign, so both are resolved to the redesigned versions (which already carry the memory/memories wording). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
967397eabd
109 changed files with 8705 additions and 1462 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
|
||||
|
|
|
|||
|
|
@ -341,11 +341,12 @@ const client = new Supermemory({
|
|||
```
|
||||
|
||||
- **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, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@
|
|||
"self-hosting/overview",
|
||||
"self-hosting/quickstart",
|
||||
"self-hosting/configuration",
|
||||
"self-hosting/embeddings",
|
||||
"self-hosting/local-vs-enterprise"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: "Every environment variable the self-hosted server understands."
|
|||
icon: "settings"
|
||||
---
|
||||
|
||||
The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it.
|
||||
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.
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ The installer writes API keys to `~/.supermemory/env`, which is loaded on every
|
|||
|
||||
## LLM providers
|
||||
|
||||
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**:
|
||||
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 |
|
||||
|---|---|
|
||||
|
|
@ -61,7 +61,20 @@ OPENAI_MODEL=gpt-oss:20b
|
|||
|
||||
Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
|
||||
|
||||
## Embedding performance
|
||||
## 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:
|
||||
|
||||
|
|
@ -128,8 +141,13 @@ Any other environment variables you may find referenced in the codebase are plat
|
|||
# Persistent data location
|
||||
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
|
||||
|
||||
# One LLM provider
|
||||
# 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.
|
||||
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
|
||||
|
|
@ -24,7 +24,7 @@ No Docker. No database to provision. No config files. It boots in seconds with e
|
|||
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** — vectors are computed on your machine. Nothing is sent anywhere to be embedded.
|
||||
- **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.
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ Self-hosted is free, open source, and great for local development, air-gapped en
|
|||
|---|---|---|
|
||||
| Full Memory API | ✅ | ✅ |
|
||||
| Hybrid semantic search | ✅ | ✅ |
|
||||
| Local embeddings | ✅ | Managed |
|
||||
| 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) | — | ✅ |
|
||||
|
|
@ -75,11 +75,14 @@ If you outgrow a single machine — or want connectors, MCP, and the best-tuned
|
|||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -47,9 +47,13 @@ First boot sets everything up — the embedded Supermemory graph engine, local e
|
|||
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. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
|
||||
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>
|
||||
|
|
@ -141,10 +145,13 @@ By default, all state lives in a single directory you can back up or move:
|
|||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -190,6 +208,7 @@ bun run test:e2e
|
|||
| `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 |
|
||||
|
||||
|
|
|
|||
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)
|
||||
})
|
||||
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 "./server/client"
|
||||
import { formatMemoriesList } from "./server/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.")
|
||||
})
|
||||
})
|
||||
51
apps/mcp/src/server/format.ts
Normal file
51
apps/mcp/src/server/format.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { DocumentsApiResponse } from "./client"
|
||||
|
||||
// Listing must stay lightweight: memory entries are extracted facts, not raw
|
||||
// document content, so responses fit client output limits at the max 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")
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import * as addMemory from "./add-memory"
|
|||
import * as fetchGraphData from "./fetch-graph-data"
|
||||
import * as guidedSave from "./guided-save"
|
||||
import * as listContainerTags from "./list-container-tags"
|
||||
import * as listMemories from "./list-memories"
|
||||
import * as memoryGraph from "./memory-graph"
|
||||
import * as saveMemory from "./save-memory"
|
||||
import * as searchMemory from "./search-memory"
|
||||
|
|
@ -15,6 +16,7 @@ import * as whoAmI from "./who-am-i"
|
|||
export function registerAllTools(deps: ToolDeps) {
|
||||
// Always available
|
||||
searchMemory.register(deps)
|
||||
listMemories.register(deps)
|
||||
listContainerTags.register(deps)
|
||||
whoAmI.register(deps)
|
||||
selectWorkspace.register(deps)
|
||||
|
|
|
|||
75
apps/mcp/src/server/tools/list-memories.ts
Normal file
75
apps/mcp/src/server/tools/list-memories.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { z } from "zod"
|
||||
import { formatMemoriesList } from "../format"
|
||||
import type { ToolDeps } from "./types"
|
||||
|
||||
export function register(deps: ToolDeps) {
|
||||
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
|
||||
.hasRootContainerTag
|
||||
? {}
|
||||
: {
|
||||
containerTag: z
|
||||
.string()
|
||||
.max(128, "Container tag exceeds maximum length")
|
||||
.optional(),
|
||||
}
|
||||
|
||||
const inputSchema = {
|
||||
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)",
|
||||
),
|
||||
...containerTagField,
|
||||
}
|
||||
|
||||
deps.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. For finding memories relevant to a topic, use search_memory instead.",
|
||||
inputSchema,
|
||||
},
|
||||
async (rawArgs) => {
|
||||
const args = rawArgs as {
|
||||
page?: number
|
||||
limit?: number
|
||||
containerTag?: string
|
||||
}
|
||||
try {
|
||||
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
|
||||
return deps.errorResult(
|
||||
new Error(
|
||||
`No read access to container tag '${args.containerTag}'.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
|
||||
const client = deps.getClient(effectiveTag)
|
||||
const containerTags = effectiveTag ? [effectiveTag] : undefined
|
||||
const data = await client.getDocuments(
|
||||
containerTags,
|
||||
args.page ?? 1,
|
||||
args.limit ?? 10,
|
||||
)
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatMemoriesList(data) }],
|
||||
}
|
||||
} catch (error) {
|
||||
return deps.errorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export function useHostContext(): McpUiHostContext | null {
|
|||
app.onhostcontextchanged = handler
|
||||
return () => {
|
||||
if (app.onhostcontextchanged === handler) {
|
||||
app.onhostcontextchanged = undefined
|
||||
app.onhostcontextchanged = () => {}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -75,10 +75,10 @@ export function useViewState(): {
|
|||
}
|
||||
|
||||
return () => {
|
||||
app.ontoolinput = undefined
|
||||
app.ontoolinputpartial = undefined
|
||||
app.ontoolcancelled = undefined
|
||||
app.ontoolresult = undefined
|
||||
app.ontoolinput = () => {}
|
||||
app.ontoolinputpartial = () => {}
|
||||
app.ontoolcancelled = () => {}
|
||||
app.ontoolresult = () => {}
|
||||
app.onerror = undefined
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts"],
|
||||
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { BrainShell } from "@/components/onboarding-brain/shell"
|
||||
import {
|
||||
|
|
@ -16,6 +18,7 @@ import {
|
|||
type SourcesValues,
|
||||
} from "@/components/onboarding-brain/step-sources"
|
||||
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
|
||||
import { CompanyBrainOnboarding } from "@/components/onboarding-brain/company-brain-onboarding"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import {
|
||||
StepTeam,
|
||||
|
|
@ -31,10 +34,14 @@ import {
|
|||
generateOrgSlug,
|
||||
generateUsername,
|
||||
workspaceDomainFromEmail,
|
||||
workspaceNameFromDomain,
|
||||
workspaceNameFromEmail,
|
||||
type CompanyBrainConfirmResult,
|
||||
} from "@/components/onboarding-brain/types"
|
||||
|
||||
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const countsAsConnectedSource = (state: unknown) =>
|
||||
state === "connected" || state === "waitlist"
|
||||
|
|
@ -52,6 +59,7 @@ const getErrorMessage = (error: unknown, fallback: string) => {
|
|||
export default function BrainOnboardingPage() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const queryClient = useQueryClient()
|
||||
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||
useAuth()
|
||||
|
||||
|
|
@ -167,13 +175,17 @@ export default function BrainOnboardingPage() {
|
|||
[router],
|
||||
)
|
||||
|
||||
// Team/company-brain routes everything into the shared Team Brain that
|
||||
// provisioning creates (sm_org_shared) — not a workspace-name slug space.
|
||||
const containerTag = useMemo(
|
||||
() =>
|
||||
containerTagFromWorkspace(
|
||||
about.workspaceName || suggestedWorkspaceName,
|
||||
mode,
|
||||
),
|
||||
[about.workspaceName, suggestedWorkspaceName, mode],
|
||||
allowTeam && mode === "team"
|
||||
? SHARED_TEAM_BRAIN_TAG
|
||||
: containerTagFromWorkspace(
|
||||
about.workspaceName || suggestedWorkspaceName,
|
||||
mode,
|
||||
),
|
||||
[allowTeam, about.workspaceName, suggestedWorkspaceName, mode],
|
||||
)
|
||||
|
||||
const isScale = useMemo(() => {
|
||||
|
|
@ -230,65 +242,78 @@ export default function BrainOnboardingPage() {
|
|||
const [creatingOrg, setCreatingOrg] = useState(false)
|
||||
const creatingOrgRef = useRef(false)
|
||||
|
||||
const ensureOrg = useCallback(async () => {
|
||||
if (!forceCreate && organizations && organizations.length > 0) return
|
||||
const name = (about.workspaceName || suggestedWorkspaceName).trim()
|
||||
const slug = generateOrgSlug(name)
|
||||
const effectiveMode = allowTeam ? mode : "personal"
|
||||
const metadata: BrainMetadata & { signupSource: string } = {
|
||||
signupSource: "consumer",
|
||||
brainOnboardingVersion: "v1",
|
||||
brainMode: effectiveMode,
|
||||
brainWorkspaceName: name,
|
||||
brainWorkspaceDomain:
|
||||
effectiveMode === "team" ? about.workspaceDomain || domain : null,
|
||||
brainContainerTag: containerTag,
|
||||
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
||||
}
|
||||
const result = await authClient.organization.create({
|
||||
name,
|
||||
slug,
|
||||
metadata,
|
||||
})
|
||||
if (result.error || !result.data?.slug) {
|
||||
throw new Error(
|
||||
getErrorMessage(result.error, "Organization was not created."),
|
||||
)
|
||||
}
|
||||
await setActiveOrg(result.data.slug)
|
||||
if (about.name.trim()) {
|
||||
await authClient.updateUser({
|
||||
name: about.name.trim(),
|
||||
displayUsername: about.name.trim(),
|
||||
username: generateUsername(about.name),
|
||||
const ensureOrg = useCallback(
|
||||
async (domainOverride?: string): Promise<boolean> => {
|
||||
if (!forceCreate && organizations && organizations.length > 0)
|
||||
return false
|
||||
const name = (
|
||||
domainOverride
|
||||
? workspaceNameFromDomain(domainOverride)
|
||||
: about.workspaceName || suggestedWorkspaceName
|
||||
).trim()
|
||||
const slug = generateOrgSlug(name)
|
||||
const effectiveMode = allowTeam ? mode : "personal"
|
||||
const metadata: BrainMetadata & { signupSource: string } = {
|
||||
signupSource: "consumer",
|
||||
brainOnboardingVersion: "v1",
|
||||
brainMode: effectiveMode,
|
||||
brainWorkspaceName: name,
|
||||
brainWorkspaceDomain:
|
||||
effectiveMode === "team"
|
||||
? domainOverride || about.workspaceDomain || domain
|
||||
: null,
|
||||
brainContainerTag: containerTag,
|
||||
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
||||
}
|
||||
const result = await authClient.organization.create({
|
||||
name,
|
||||
slug,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
await refetchOrganizations()
|
||||
analytics.onboardingWorkspaceCreated({
|
||||
if (result.error || !result.data?.slug) {
|
||||
throw new Error(
|
||||
getErrorMessage(result.error, "Organization was not created."),
|
||||
)
|
||||
}
|
||||
await setActiveOrg(result.data.slug)
|
||||
if (about.name.trim()) {
|
||||
await authClient.updateUser({
|
||||
name: about.name.trim(),
|
||||
displayUsername: about.name.trim(),
|
||||
username: generateUsername(about.name),
|
||||
})
|
||||
}
|
||||
await refetchOrganizations()
|
||||
analytics.onboardingWorkspaceCreated({
|
||||
mode,
|
||||
has_about: Boolean(about.about.trim()),
|
||||
has_domain: Boolean(
|
||||
mode === "team" && (about.workspaceDomain || domain),
|
||||
),
|
||||
})
|
||||
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
|
||||
if (forceCreate) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("new")
|
||||
url.searchParams.delete("name")
|
||||
router.replace(url.pathname + url.search, { scroll: false })
|
||||
}
|
||||
return true
|
||||
},
|
||||
[
|
||||
organizations,
|
||||
about,
|
||||
suggestedWorkspaceName,
|
||||
mode,
|
||||
has_about: Boolean(about.about.trim()),
|
||||
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
|
||||
})
|
||||
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
|
||||
if (forceCreate) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("new")
|
||||
url.searchParams.delete("name")
|
||||
router.replace(url.pathname + url.search, { scroll: false })
|
||||
}
|
||||
}, [
|
||||
organizations,
|
||||
about,
|
||||
suggestedWorkspaceName,
|
||||
mode,
|
||||
allowTeam,
|
||||
domain,
|
||||
containerTag,
|
||||
setActiveOrg,
|
||||
refetchOrganizations,
|
||||
forceCreate,
|
||||
router,
|
||||
])
|
||||
allowTeam,
|
||||
domain,
|
||||
containerTag,
|
||||
setActiveOrg,
|
||||
refetchOrganizations,
|
||||
forceCreate,
|
||||
router,
|
||||
],
|
||||
)
|
||||
|
||||
const handleAboutContinue = useCallback(async () => {
|
||||
if (creatingOrgRef.current) return
|
||||
|
|
@ -315,6 +340,73 @@ export default function BrainOnboardingPage() {
|
|||
}
|
||||
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
||||
|
||||
// Company Brain (team) onboarding is a single research surface, no stepper.
|
||||
const isCompanyBrain = allowTeam && mode === "team"
|
||||
|
||||
const handleBrainConfirm = useCallback(
|
||||
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
|
||||
if (creatingOrgRef.current) return { ok: false }
|
||||
creatingOrgRef.current = true
|
||||
setCreatingOrg(true)
|
||||
try {
|
||||
const workspaceName = workspaceNameFromDomain(confirmedDomain)
|
||||
setAbout((a) => ({
|
||||
...a,
|
||||
workspaceDomain: confirmedDomain,
|
||||
workspaceName: workspaceName || a.workspaceName,
|
||||
}))
|
||||
const orgCreated = await ensureOrg(confirmedDomain)
|
||||
// Re-entering onboarding on an existing org ("Try onboarding") must
|
||||
// kick research from the client. New orgs rely on the signup hook after
|
||||
// provisioning — a duplicate /start races and can strand the DO task.
|
||||
if (!orgCreated) {
|
||||
const started = await fetch(`${BACKEND}/brain/research/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify({ domain: confirmedDomain }),
|
||||
})
|
||||
.then((res) => res.ok)
|
||||
.catch(() => false)
|
||||
// Don't advance into the research phase if it never started, else the
|
||||
// poller sits on empty state until timeout.
|
||||
if (!started) {
|
||||
toast.error("Couldn't start research", {
|
||||
description: "Please try again in a moment.",
|
||||
})
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
|
||||
// ensureOrg already fires this when it creates the org; only emit here
|
||||
// for the existing-org path to avoid a duplicate event.
|
||||
if (!orgCreated) {
|
||||
analytics.onboardingWorkspaceCreated({
|
||||
mode: "team",
|
||||
has_about: false,
|
||||
has_domain: Boolean(confirmedDomain),
|
||||
})
|
||||
}
|
||||
return { ok: true, serverSchedulesResearch: orgCreated }
|
||||
} catch (e) {
|
||||
const message = getErrorMessage(e, "Organization was not created.")
|
||||
console.error("Failed to create organization:", e)
|
||||
analytics.onboardingWorkspaceCreateFailed({ error: message })
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again.",
|
||||
})
|
||||
return { ok: false }
|
||||
} finally {
|
||||
creatingOrgRef.current = false
|
||||
setCreatingOrg(false)
|
||||
}
|
||||
},
|
||||
[ensureOrg, queryClient],
|
||||
)
|
||||
|
||||
const [sendingInvites, setSendingInvites] = useState(false)
|
||||
const sendingInvitesRef = useRef(false)
|
||||
|
||||
|
|
@ -370,6 +462,19 @@ export default function BrainOnboardingPage() {
|
|||
|
||||
const mcpUrl = "https://mcp.supermemory.ai/mcp"
|
||||
|
||||
if (isCompanyBrain) {
|
||||
return (
|
||||
<CompanyBrainOnboarding
|
||||
name={about.name || user?.name || ""}
|
||||
avatarUrl={user?.image ?? null}
|
||||
domain={about.workspaceDomain || domain || ""}
|
||||
submitting={creatingOrg}
|
||||
onConfirm={handleBrainConfirm}
|
||||
onDone={finish}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BrainShell
|
||||
step={step}
|
||||
|
|
@ -397,7 +502,6 @@ export default function BrainOnboardingPage() {
|
|||
{step === "sources" && (
|
||||
<StepSources
|
||||
containerTag={containerTag}
|
||||
workspaceName={about.workspaceName || suggestedWorkspaceName}
|
||||
mode={mode}
|
||||
values={sources}
|
||||
onChange={setSources}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ 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"
|
||||
import { getBackendUrl, resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
|
|
@ -276,7 +276,7 @@ export default function LoginPage() {
|
|||
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)}`,
|
||||
`${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,29 +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 === "0.0.0.0" ||
|
||||
lowerHost === "::1" ||
|
||||
lowerHost === "::" ||
|
||||
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\./,
|
||||
/^169\.254\./, // Link-local / Metadata service
|
||||
]
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"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, Granola, Notion, OneDrive } from "@ui/assets/icons"
|
||||
|
|
@ -309,7 +309,7 @@ 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)
|
||||
|
|
@ -398,7 +398,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.",
|
||||
)
|
||||
|
|
@ -557,7 +557,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
type="button"
|
||||
onClick={() => handleConnect("google-drive")}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
!connectorAccess ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
|
|
@ -605,14 +605,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</div>
|
||||
) : provider === "granola" ? (
|
||||
<>
|
||||
{!isProUser && (
|
||||
{!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 (!isProUser) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -621,7 +621,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
disabled={isUpgrading || autumn.isLoading}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
{!isProUser
|
||||
{!connectorAccess
|
||||
? isUpgrading || autumn.isLoading
|
||||
? "Upgrading..."
|
||||
: "Upgrade"
|
||||
|
|
@ -634,7 +634,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
handleConnect(provider as ConnectorProvider)
|
||||
}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
!connectorAccess ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
|
|
@ -681,7 +681,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 ? (
|
||||
|
|
@ -792,7 +792,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<DropdownMenuItem
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
onClick={() => {
|
||||
if (!isProUser) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -804,14 +804,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<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
|
||||
{!isProUser && (
|
||||
{!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">
|
||||
{isProUser
|
||||
{connectorAccess
|
||||
? "Meeting notes & transcripts"
|
||||
: "Upgrade to Pro"}
|
||||
</span>
|
||||
|
|
@ -866,7 +866,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 ? (
|
||||
|
|
@ -945,8 +945,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
/>
|
||||
|
||||
<GranolaConnectModal
|
||||
open={isProUser && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && isProUser)}
|
||||
open={connectorAccess && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && connectorAccess)}
|
||||
containerTags={[selectedProject]}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import {
|
|||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { getToolDocumentSpace } from "@/lib/plugin-space"
|
||||
import { getBackendUrl } from "@/lib/url-helpers"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -134,6 +135,7 @@ export function AppExperience() {
|
|||
const { viewMode, setViewMode } = useViewMode()
|
||||
useLegacyViewRedirect()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const backendUrl = getBackendUrl()
|
||||
|
||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
||||
useEffect(() => {
|
||||
|
|
@ -331,7 +333,7 @@ export function AppExperience() {
|
|||
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}`
|
||||
const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
|
|
@ -345,22 +347,19 @@ export function AppExperience() {
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const response = await fetch(`${backendUrl}/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")
|
||||
|
|
@ -404,10 +403,9 @@ export function AppExperience() {
|
|||
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" },
|
||||
)
|
||||
const response = await fetch(`${backendUrl}/v3/memory-of-day`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as MemoryOfDay | null
|
||||
if (data) {
|
||||
|
|
@ -814,7 +812,7 @@ export function AppExperience() {
|
|||
{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 && (
|
||||
{isDashboardShell && !isCompanyBrain && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
|
|
|
|||
119
apps/web/components/brain-connector-icons.tsx
Normal file
119
apps/web/components/brain-connector-icons.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { Granola, Notion } from "@ui/assets/icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Linear</title>
|
||||
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SentryMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Sentry</title>
|
||||
<path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PostHogMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>PostHog</title>
|
||||
<path d="M9.854 14.5 5 9.647.854 5.5A.5.5 0 0 0 0 5.854V8.44a.5.5 0 0 0 .146.353L5 13.647l.147.146L9.854 18.5l.146.147v-.049c.065.03.134.049.207.049h2.586a.5.5 0 0 0 .353-.854L9.854 14.5zm0-5-4-4a.487.487 0 0 0-.409-.144.515.515 0 0 0-.356.21.493.493 0 0 0-.089.288V8.44a.5.5 0 0 0 .147.353l9 9a.5.5 0 0 0 .853-.354v-2.585a.5.5 0 0 0-.146-.354l-5-5zm1-4a.5.5 0 0 0-.854.354V8.44a.5.5 0 0 0 .147.353l4 4a.5.5 0 0 0 .853-.354V9.854a.5.5 0 0 0-.146-.354l-4-4zm12.647 11.515a3.863 3.863 0 0 1-2.232-1.1l-4.708-4.707a.5.5 0 0 0-.854.354v6.585a.5.5 0 0 0 .5.5H23.5a.5.5 0 0 0 .5-.5v-.6c0-.276-.225-.497-.499-.532zm-5.394.032a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zM.854 15.5a.5.5 0 0 0-.854.354v2.293a.5.5 0 0 0 .5.5h2.293c.222 0 .39-.135.462-.309a.493.493 0 0 0-.109-.545L.854 15.501zM5 14.647.854 10.5a.5.5 0 0 0-.854.353v2.586a.5.5 0 0 0 .146.353L4.854 18.5l.146.147h2.793a.5.5 0 0 0 .353-.854L5 14.647z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LetterMark({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex size-full items-center justify-center rounded-[8px] text-[15px] font-bold text-white",
|
||||
)}
|
||||
style={{ background: color }}
|
||||
>
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Real brand logos where we have them, a branded lettermark otherwise.
|
||||
export function brainConnectorIcon(
|
||||
slug: string,
|
||||
name: string,
|
||||
className = "size-[18px]",
|
||||
): React.ReactNode {
|
||||
switch (slug) {
|
||||
case "github":
|
||||
return <GithubMark className={cn(className, "text-[#FAFAFA]")} />
|
||||
case "linear":
|
||||
return <LinearMark className={cn(className, "text-[#5E6AD2]")} />
|
||||
case "slack":
|
||||
return <SlackMark className={className} />
|
||||
case "notion":
|
||||
return <Notion className={className} />
|
||||
case "granola":
|
||||
return <Granola className={className} />
|
||||
case "sentry":
|
||||
return <SentryMark className={cn(className, "text-[#FAFAFA]")} />
|
||||
case "posthog":
|
||||
return <PostHogMark className={cn(className, "text-[#F1A82C]")} />
|
||||
default:
|
||||
return <LetterMark label={name} color="#1E293B" />
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import { $fetch } from "@lib/api"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
|
||||
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { ConnectionsBoard } from "./connections-board"
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ type RecentDoc = {
|
|||
id?: string
|
||||
title?: string | null
|
||||
createdAt?: string | Date | null
|
||||
updatedAt?: string | Date | null
|
||||
}
|
||||
|
||||
function useBrainOverview() {
|
||||
|
|
@ -50,6 +53,27 @@ function useBrainOverview() {
|
|||
enabled,
|
||||
})
|
||||
|
||||
const lastUpdated = useQuery({
|
||||
queryKey: ["brain-last-updated", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
sort: "updatedAt",
|
||||
order: "desc",
|
||||
containerTags: [],
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (res.error) return null
|
||||
const docs = (res.data as { documents?: RecentDoc[] })?.documents
|
||||
return docs?.[0]?.updatedAt ?? null
|
||||
},
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const connectors = useQuery({
|
||||
queryKey: ["brain-home", "connectors"],
|
||||
queryFn: async () => {
|
||||
|
|
@ -103,11 +127,18 @@ function useBrainOverview() {
|
|||
(brain.data?.slack ? 1 : 0) +
|
||||
(connectors.data?.length ?? 0)
|
||||
|
||||
const currentRole = org?.members
|
||||
?.find((m) => m.userId === user?.id)
|
||||
?.role?.toLowerCase()
|
||||
|
||||
return {
|
||||
loading: docs.isPending,
|
||||
recentDocs: docs.data?.documents ?? [],
|
||||
lastUpdatedAt: lastUpdated.data ?? null,
|
||||
memoriesCount,
|
||||
connectedCount,
|
||||
membersCount: org?.members?.length ?? 0,
|
||||
canInvite: currentRole === "owner" || currentRole === "admin",
|
||||
hasSource: connectedCount > 0,
|
||||
hasAgent: mcp.data ?? false,
|
||||
hasMemory: memoriesCount > 0,
|
||||
|
|
@ -119,22 +150,33 @@ export function BrainHomeView() {
|
|||
const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
|
||||
Boolean,
|
||||
).length
|
||||
const showGettingStarted = !o.loading && stepsDone < 3
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||
<StatsRow
|
||||
memories={o.memoriesCount}
|
||||
connected={o.connectedCount}
|
||||
members={o.membersCount}
|
||||
canInvite={o.canInvite}
|
||||
setupDone={stepsDone}
|
||||
lastUpdatedAt={o.lastUpdatedAt}
|
||||
/>
|
||||
<ConnectionsBoard />
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-6",
|
||||
showGettingStarted && "lg:grid-cols-[minmax(0,1fr)_340px]",
|
||||
)}
|
||||
>
|
||||
<RecentMemories docs={o.recentDocs} loading={o.loading} />
|
||||
<GettingStarted
|
||||
hasSource={o.hasSource}
|
||||
hasAgent={o.hasAgent}
|
||||
hasMemory={o.hasMemory}
|
||||
/>
|
||||
{showGettingStarted && (
|
||||
<GettingStarted
|
||||
hasSource={o.hasSource}
|
||||
hasAgent={o.hasAgent}
|
||||
hasMemory={o.hasMemory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -143,27 +185,67 @@ export function BrainHomeView() {
|
|||
function StatsRow({
|
||||
memories,
|
||||
connected,
|
||||
members,
|
||||
canInvite,
|
||||
setupDone,
|
||||
lastUpdatedAt,
|
||||
}: {
|
||||
memories: number
|
||||
connected: number
|
||||
members: number
|
||||
canInvite: boolean
|
||||
setupDone: number
|
||||
lastUpdatedAt: string | Date | null
|
||||
}) {
|
||||
const tiles = [
|
||||
const { openSettings } = useSettingsModal()
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
|
||||
const onInvite = () => {
|
||||
setInvite("1")
|
||||
openSettings("account")
|
||||
}
|
||||
|
||||
const tiles: {
|
||||
label: string
|
||||
value: string
|
||||
action?: React.ReactNode
|
||||
}[] = [
|
||||
{ label: "Memories", value: memories.toLocaleString() },
|
||||
{ label: "Connected sources", value: String(connected) },
|
||||
{ label: "Setup", value: `${setupDone}/3` },
|
||||
{
|
||||
label: "Active members",
|
||||
value: String(members),
|
||||
action: canInvite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
</button>
|
||||
) : undefined,
|
||||
},
|
||||
setupDone < 3
|
||||
? { label: "Setup", value: `${setupDone}/3` }
|
||||
: {
|
||||
label: "Last updated",
|
||||
value: formatWhen(lastUpdatedAt) || "—",
|
||||
},
|
||||
]
|
||||
return (
|
||||
<section
|
||||
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
|
||||
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
|
||||
style={cardStyle}
|
||||
>
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
</p>
|
||||
{t.action}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, Loader2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
||||
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
|
||||
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
|
||||
// Example prompts on the right card — can include apps not shown on the left.
|
||||
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
const cardStyle = {
|
||||
boxShadow:
|
||||
|
|
@ -22,64 +26,128 @@ const tileStyle = {
|
|||
"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 ConnRow = { toolkit: string; org: boolean; user: boolean }
|
||||
type AuthType = "oauth" | "static" | "none"
|
||||
type CatalogEntry = {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
authType: AuthType
|
||||
tokenHint?: string
|
||||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
// Example asks that connecting each app unlocks for the Slack agent.
|
||||
const AGENT_PROMPTS: Record<string, string> = {
|
||||
linear: "What's blocking the sprint?",
|
||||
github: "Summarize the open PRs on auth",
|
||||
sentry: "Any new errors since the deploy?",
|
||||
notion: "Find our launch checklist",
|
||||
posthog: "How's activation trending this week?",
|
||||
plain: "What are customers asking about?",
|
||||
granola: "Recap yesterday's standup",
|
||||
}
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function ConnectionsBoard() {
|
||||
const [brainRows, setBrainRows] = useState<ConnRow[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slack, setSlack] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const loadBrain = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, s] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
const [cat, conn, s] = await Promise.all([
|
||||
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
||||
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (c.ok)
|
||||
setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
// Parse each response independently so one bad payload can't strand the others.
|
||||
try {
|
||||
if (cat.ok) {
|
||||
const data: { catalog?: CatalogEntry[] } = await cat.json()
|
||||
setCatalog(data.catalog ?? [])
|
||||
} else setCatalog([])
|
||||
} catch {
|
||||
setCatalog([])
|
||||
}
|
||||
try {
|
||||
if (conn.ok) {
|
||||
const data: { connections?: ConnRow[] } = await conn.json()
|
||||
setRows(data.connections ?? [])
|
||||
} else setRows([])
|
||||
} catch {
|
||||
setRows([])
|
||||
}
|
||||
try {
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
} catch {
|
||||
setCatalog([])
|
||||
setRows([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrain()
|
||||
const onFocus = () => void loadBrain()
|
||||
void load()
|
||||
const onFocus = () => void load()
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [loadBrain])
|
||||
}, [load])
|
||||
|
||||
const { data: connectors } = useQuery({
|
||||
queryKey: ["brain-home", "connectors"],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (res.error) return [] as Array<{ provider?: string }>
|
||||
return (res.data ?? []) as Array<{ provider?: string }>
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const isConnected = (slug: string) =>
|
||||
rows.some((r) => r.serverSlug === slug && r.status === "active")
|
||||
|
||||
const connectorConnected = (provider: string) =>
|
||||
Boolean(connectors?.some((c) => c.provider === provider))
|
||||
|
||||
const connectBrain = async (toolkit: string) => {
|
||||
setBusy(`brain:${toolkit}`)
|
||||
const connect = async (entry: CatalogEntry) => {
|
||||
setBusy(entry.slug)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=user`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (entry.authType === "static") {
|
||||
const token = window.prompt(
|
||||
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
||||
)
|
||||
if (!token) return
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, shared: false }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't connect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
shared: false,
|
||||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { url?: string }
|
||||
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||
else toast.error("Couldn't start the connection.")
|
||||
const data: { authUrl?: string; ok?: boolean } = await res.json()
|
||||
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
|
||||
else if (data.ok) {
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
} else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
|
|
@ -87,99 +155,259 @@ export function ConnectionsBoard() {
|
|||
}
|
||||
}
|
||||
|
||||
const connectConnector = async (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
) => {
|
||||
setBusy(`conn:${provider}`)
|
||||
try {
|
||||
const res = await $fetch("@post/connections/:provider", {
|
||||
params: { provider },
|
||||
body: { redirectUrl: window.location.href, containerTags: [] },
|
||||
})
|
||||
const data = res.data as { authLink?: string } | undefined
|
||||
if (data?.authLink) window.location.href = data.authLink
|
||||
else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const brainConnected = (toolkit: string) =>
|
||||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.org) ||
|
||||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.user)
|
||||
const { openSettings } = useSettingsModal()
|
||||
const apps = catalog ?? []
|
||||
const loading = catalog === null
|
||||
const featured = FEATURED_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const remainingCount = Math.max(apps.length - featured.length, 0)
|
||||
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{slack && !slack.connected && <SlackBanner />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Group
|
||||
title="Tool integrations"
|
||||
subtitle="Apps your agents can act on."
|
||||
<div className="grid items-start gap-4 lg:grid-cols-5">
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<AppCard
|
||||
icon={<GithubMark className="size-5 text-[#fafafa]" />}
|
||||
name="GitHub"
|
||||
subtitle="Repos, pull requests and issues."
|
||||
connected={brainConnected("github")}
|
||||
busy={busy === "brain:github"}
|
||||
onConnect={() => connectBrain("github")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<LinearMark className="size-5 text-[#5E6AD2]" />}
|
||||
name="Linear"
|
||||
subtitle="Issues, projects and cycles."
|
||||
connected={brainConnected("linear")}
|
||||
busy={busy === "brain:linear"}
|
||||
onConnect={() => connectBrain("linear")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Connectors"
|
||||
subtitle="Sync documents into your brain."
|
||||
cta={
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
All connectors
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<AppCard
|
||||
icon={<GoogleDrive className="size-5" />}
|
||||
name="Google Drive"
|
||||
subtitle="Docs, sheets and slides."
|
||||
connected={connectorConnected("google-drive")}
|
||||
busy={busy === "conn:google-drive"}
|
||||
onConnect={() => connectConnector("google-drive")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<Notion className="size-5" />}
|
||||
name="Notion"
|
||||
subtitle="Pages, databases and blocks."
|
||||
connected={connectorConnected("notion")}
|
||||
busy={busy === "conn:notion"}
|
||||
onConnect={() => connectConnector("notion")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
|
||||
name="OneDrive"
|
||||
subtitle="Files from Microsoft 365."
|
||||
connected={connectorConnected("onedrive")}
|
||||
busy={busy === "conn:onedrive"}
|
||||
onConnect={() => connectConnector("onedrive")}
|
||||
/>
|
||||
</Group>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={i < featured.length - 1 || remainingCount > 0}
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<MoreTile
|
||||
count={remainingCount}
|
||||
onClick={() => openSettings("company-brain")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AgentPreview
|
||||
apps={previewApps}
|
||||
isConnected={isConnected}
|
||||
connectedCount={connectedCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentPreview({
|
||||
apps,
|
||||
isConnected,
|
||||
connectedCount,
|
||||
}: {
|
||||
apps: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
connectedCount: number
|
||||
}) {
|
||||
const prompts = apps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
.slice(0, 6)
|
||||
.map((a) => ({
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
prompt: AGENT_PROMPTS[a.slug],
|
||||
connected: isConnected(a.slug),
|
||||
}))
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlackMark className="size-4" />
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Ask in Slack
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.slug}
|
||||
className={cn(
|
||||
"grid min-h-11 grid-cols-[28px_1fr] items-center gap-x-3 px-3 py-2.5 transition-opacity",
|
||||
i < prompts.length - 1 && "border-b border-[#1B1F24]",
|
||||
p.connected ? "opacity-100" : "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-7 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{brainConnectorIcon(p.slug, p.name, "size-4")}
|
||||
</div>
|
||||
<p className="text-[12.5px] font-medium leading-snug text-[#d4d4d8]">
|
||||
"{p.prompt}"
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppTile({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
connected,
|
||||
busy,
|
||||
onConnect,
|
||||
showDivider = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
connected: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
|
||||
showDivider && "border-b border-[#1B1F24]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex w-[88px] shrink-0 items-center justify-end gap-1.5 text-[12px] font-medium text-[#fafafa]">
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex w-[88px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 py-1.5 text-[12px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[52px] w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-dashed border-[#2A3140] bg-[#080B0F] text-[11px] font-semibold text-[#737373]"
|
||||
style={tileStyle}
|
||||
>
|
||||
+{count}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
|
||||
{count} more {count === 1 ? "app" : "apps"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
Notion, PostHog, Plain and more
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[88px] shrink-0 justify-end">
|
||||
<ArrowRight className="size-4 text-[#52525B]" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
|
||||
showDivider && "border-b border-[#1B1F24]",
|
||||
)}
|
||||
>
|
||||
<div className="size-9 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-28 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
<div className="h-7 w-[88px] animate-pulse rounded-full bg-[#1c1f24]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
|
|
@ -230,164 +458,3 @@ function SlackBanner() {
|
|||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
title,
|
||||
subtitle,
|
||||
accent,
|
||||
cta,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
accent?: boolean
|
||||
cta?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
{accent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-8 left-8 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-1 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{cta}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
connected,
|
||||
busy,
|
||||
onConnect,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
connected: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] p-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#fafafa]">
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full bg-[#0D121A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Linear</title>
|
||||
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1111,7 +1111,7 @@ export function ChatSidebar({
|
|||
const params = new URLSearchParams({ projectId: chatProject })
|
||||
if (historyScope === "all") params.set("scope", "all")
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`,
|
||||
`${chatApiBase}/chat/threads?${params.toString()}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
|
|
@ -1123,7 +1123,7 @@ export function ChatSidebar({
|
|||
} finally {
|
||||
setIsLoadingThreads(false)
|
||||
}
|
||||
}, [chatProject, historyScope])
|
||||
}, [chatApiBase, chatProject, historyScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHistoryOpen) return
|
||||
|
|
@ -1134,10 +1134,9 @@ export function ChatSidebar({
|
|||
const loadThread = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
const response = await fetch(`${chatApiBase}/chat/threads/${id}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const uiMessages = data.messages.map(
|
||||
|
|
@ -1179,7 +1178,7 @@ export function ChatSidebar({
|
|||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
},
|
||||
[setThreadId],
|
||||
[chatApiBase, setThreadId],
|
||||
)
|
||||
|
||||
// Auto-restore thread from URL on mount (e.g. reload or direct link)
|
||||
|
|
@ -1197,7 +1196,7 @@ export function ChatSidebar({
|
|||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
`${chatApiBase}/chat/threads/${threadId}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
|
|
@ -1213,7 +1212,7 @@ export function ChatSidebar({
|
|||
setConfirmingDeleteId(null)
|
||||
}
|
||||
},
|
||||
[currentChatId, handleNewChat],
|
||||
[chatApiBase, currentChatId, handleNewChat],
|
||||
)
|
||||
|
||||
const formatRelativeTime = (isoString: string): string => {
|
||||
|
|
|
|||
604
apps/web/components/company-brain-header.tsx
Normal file
604
apps/web/components/company-brain-header.tsx
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
Building2,
|
||||
Code2,
|
||||
ExternalLink,
|
||||
Home,
|
||||
LifeBuoy,
|
||||
Link2,
|
||||
LayoutGrid,
|
||||
MenuIcon,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
UserPlus,
|
||||
ChevronRight,
|
||||
Sun,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useCallback } from "react"
|
||||
import { DomainLogo } from "@/components/onboarding-brain/step-about"
|
||||
import { FeedbackModal } from "@/components/feedback-modal"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useProject } from "@/stores"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
|
||||
interface CompanyBrainHeaderProps {
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
const brainItemClass = (active: boolean) =>
|
||||
cn(
|
||||
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
|
||||
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
|
||||
active ? "bg-white/[0.06] text-white" : "text-white/85",
|
||||
)
|
||||
|
||||
const brainTileClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
|
||||
active
|
||||
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
|
||||
: "border-white/[0.08] bg-white/[0.04] text-white/70",
|
||||
)
|
||||
|
||||
const menuItemClass =
|
||||
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
||||
const circleNavClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
active
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)
|
||||
|
||||
const tabClass = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
|
||||
active
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)
|
||||
|
||||
function useSlackStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["brain-slack-status"],
|
||||
queryFn: async (): Promise<SlackStatus> => {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return { connected: false, teamName: null }
|
||||
return (await res.json()) as SlackStatus
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const isMobile = useIsMobile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useQueryState(
|
||||
"feedback",
|
||||
feedbackParam,
|
||||
)
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
const [settingsTab] = useQueryState("settings")
|
||||
const { data: slackStatus } = useSlackStatus()
|
||||
|
||||
const planByOrgId = new Map(
|
||||
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
|
||||
)
|
||||
|
||||
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
|
||||
const brandLabel = orgLabel || "Workspace"
|
||||
const domain = getBrainWorkspaceDomain(
|
||||
org?.metadata as Record<string, unknown> | string | null | undefined,
|
||||
)
|
||||
const hasOrgs = (organizations?.length ?? 0) > 0
|
||||
|
||||
const memberRole = org?.members
|
||||
?.find((m) => m.userId === user?.id)
|
||||
?.role?.toLowerCase()
|
||||
const canInvite = memberRole === "owner" || memberRole === "admin"
|
||||
|
||||
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
|
||||
const isGraph = viewMode === "graph"
|
||||
const isMemories = viewMode === "list"
|
||||
const isConnections = settingsTab === "company-brain"
|
||||
const slackConnected = slackStatus?.connected ?? false
|
||||
|
||||
const selectOrg = useCallback(
|
||||
(slug: string, isActive: boolean) => {
|
||||
if (isActive) return
|
||||
void setActiveOrg(slug).then(() => window.location.reload())
|
||||
},
|
||||
[setActiveOrg],
|
||||
)
|
||||
|
||||
const goOverview = useCallback(() => {
|
||||
void setViewMode("dashboard")
|
||||
}, [setViewMode])
|
||||
|
||||
const goGraph = useCallback(() => {
|
||||
void setViewMode("graph")
|
||||
}, [setViewMode])
|
||||
|
||||
const goMemories = useCallback(() => {
|
||||
void setViewMode("list")
|
||||
}, [setViewMode])
|
||||
|
||||
const goConnections = useCallback(() => {
|
||||
openSettings("company-brain")
|
||||
}, [openSettings])
|
||||
|
||||
const goIntegrations = useCallback(() => {
|
||||
void setViewMode("integrations")
|
||||
}, [setViewMode])
|
||||
|
||||
const handleInvite = useCallback(() => {
|
||||
setInvite("1")
|
||||
openSettings("account")
|
||||
}, [openSettings, setInvite])
|
||||
|
||||
const handleFeedback = useCallback(() => {
|
||||
void setFeedbackOpen(true)
|
||||
}, [setFeedbackOpen])
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex max-w-[min(52vw,240px)] shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
|
||||
>
|
||||
<div
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
}}
|
||||
>
|
||||
{domain ? (
|
||||
<DomainLogo domain={domain} />
|
||||
) : (
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
|
||||
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
Company Brain
|
||||
</p>
|
||||
<p className="-mt-0.5 max-w-full truncate text-sm leading-none font-semibold text-white/90 sm:text-[15px]">
|
||||
{brandLabel}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
alignOffset={12}
|
||||
className={cn(
|
||||
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
{hasOrgs && (
|
||||
<>
|
||||
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
|
||||
Switch brain
|
||||
</p>
|
||||
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
|
||||
{organizations?.map((o) => {
|
||||
const active = org?.id === o.id
|
||||
const plan = resolveOrgPlan(
|
||||
o.id,
|
||||
active,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={o.id}
|
||||
onClick={() => selectOrg(o.slug, active)}
|
||||
className={cn(brainItemClass(active))}
|
||||
>
|
||||
<span className={cn(brainTileClass(active))}>
|
||||
{o.name?.trim().charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.name}</span>
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild className={menuItemClass}>
|
||||
<Link href="/">
|
||||
<Home className="size-4 text-[#737373]" />
|
||||
Home
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
|
||||
<Link2 className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Sun className="size-4 text-[#737373]" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings()}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<DropdownMenuItem asChild className={menuItemClass}>
|
||||
<a
|
||||
href="https://console.supermemory.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Code2 className="size-4 text-[#737373]" />
|
||||
Developer console
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className={menuItemClass}>
|
||||
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="size-4 text-[#737373]" />
|
||||
supermemory.ai
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-[#3F4853]"
|
||||
aria-hidden
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
enableDelete={false}
|
||||
enableEdit={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Overview"
|
||||
aria-current={isOverview ? "page" : undefined}
|
||||
onClick={goOverview}
|
||||
className={circleNavClass(isOverview)}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Overview
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Content"
|
||||
aria-orientation="horizontal"
|
||||
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto snap-x snap-mandatory scroll-fade-x rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isGraph}
|
||||
onClick={goGraph}
|
||||
className={tabClass(isGraph)}
|
||||
>
|
||||
<GraphIcon className="size-3.5 shrink-0 sm:size-4" />
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isMemories}
|
||||
onClick={goMemories}
|
||||
className={tabClass(isMemories)}
|
||||
>
|
||||
<LayoutGrid className="size-3.5 shrink-0 sm:size-4" />
|
||||
Memories
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isConnections}
|
||||
onClick={goConnections}
|
||||
className={tabClass(isConnections)}
|
||||
>
|
||||
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
|
||||
Connections
|
||||
</button>
|
||||
</div>
|
||||
<SlackNavButton
|
||||
connected={slackConnected}
|
||||
teamName={slackStatus?.teamName ?? null}
|
||||
active={isConnections && slackConnected}
|
||||
onManage={goConnections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="z-10! flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
enableDelete={false}
|
||||
compact
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
>
|
||||
<MenuIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={goOverview}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Home className="size-4 text-[#737373]" />
|
||||
Overview
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={goGraph} className={menuItemClass}>
|
||||
<GraphIcon className="size-4 text-[#737373]" />
|
||||
Graph
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goMemories}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<LayoutGrid className="size-4 text-[#737373]" />
|
||||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<IntegrationsIcon className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Sun className="size-4 text-[#737373]" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
{slackConnected ? (
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Slack connected
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem asChild className={menuItemClass}>
|
||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
{canInvite && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleInvite}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<UserPlus className="size-4 text-[#737373]" />
|
||||
Invite teammates
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenSearch}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SearchIcon className="size-4 text-[#737373]" />
|
||||
Search
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuItem
|
||||
onClick={handleFeedback}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<LifeBuoy className="size-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings()}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canInvite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={handleInvite}
|
||||
aria-label="Invite teammates"
|
||||
>
|
||||
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Invite</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Invite teammates
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onOpenSearch}
|
||||
aria-label="Search"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Search (⌘K)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<UserProfileMenu onOpenFeedback={handleFeedback} />
|
||||
</div>
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={feedbackOpen}
|
||||
onClose={() => setFeedbackOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackNavButton({
|
||||
connected,
|
||||
teamName,
|
||||
active,
|
||||
onManage,
|
||||
}: {
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
active: boolean
|
||||
onManage: () => void
|
||||
}) {
|
||||
const label = connected
|
||||
? `Slack${teamName ? ` · ${teamName}` : ""}`
|
||||
: "Add to Slack"
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
aria-label="Add to Slack"
|
||||
className={circleNavClass(false)}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Add to Slack
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onManage}
|
||||
className={cn(circleNavClass(active), "relative")}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-[#2EB67D] ring-2 ring-[#00173C]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import {
|
|||
import { normalizePluginClientId } from "@/lib/plugin-catalog"
|
||||
import { detectPluginSpace } from "@/lib/plugin-space"
|
||||
import { useDigests } from "@/hooks/use-digests"
|
||||
import { ReviewMemoriesCard } from "@/components/review-memories-card"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -1181,6 +1182,7 @@ export function DashboardView({
|
|||
}) {
|
||||
const { user, org } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const primaryContainerTag = effectiveContainerTags?.[0]
|
||||
const _router = useRouter()
|
||||
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
|
||||
queryKey: ["dashboard-recents", effectiveContainerTags],
|
||||
|
|
@ -1600,7 +1602,8 @@ export function DashboardView({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
<div className="flex-[2] min-w-0 hidden sm:block space-y-2">
|
||||
<ReviewMemoriesCard containerTag={primaryContainerTag} />
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
|
|
@ -1617,7 +1620,8 @@ export function DashboardView({
|
|||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Suggested for you
|
||||
</p>
|
||||
<div className="max-w-sm">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<ReviewMemoriesCard containerTag={primaryContainerTag} />
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "@ui/assets/icons"
|
||||
import { Globe, FileText, FileCode, Image } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
function MCPIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -206,7 +207,7 @@ export function DocumentIcon({
|
|||
return <MCPIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
|
||||
if (isYouTubeUrl(url)) {
|
||||
return <YouTubeIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import dynamic from "next/dynamic"
|
||||
import { isTwitterUrl } from "@/lib/url-helpers"
|
||||
import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { TweetContent } from "./tweet"
|
||||
import { NotionDoc } from "./notion-doc"
|
||||
|
|
@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
|
|||
if (document.type === "google_doc") return "google_doc"
|
||||
if (document.type === "google_sheet") return "google_sheet"
|
||||
if (document.type === "google_slide") return "google_slide"
|
||||
if (document.url?.includes("youtube.com")) return "youtube"
|
||||
if (isYouTubeUrl(document.url)) return "youtube"
|
||||
if (document.type === "webpage") return "webpage"
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import { useTokenUsage } from "@/hooks/use-token-usage"
|
|||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { CompanyBrainHeader } from "@/components/company-brain-header"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
|
|
@ -65,7 +67,15 @@ const brainTileClass = (active: boolean) =>
|
|||
: "border-white/[0.08] bg-white/[0.04] text-white/70",
|
||||
)
|
||||
|
||||
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
||||
export function Header(props: HeaderProps) {
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
if (hasCompanyBrain) {
|
||||
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
|
||||
}
|
||||
return <PersonalBrainHeader {...props} />
|
||||
}
|
||||
|
||||
function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) {
|
||||
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -44,6 +43,7 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
|
|
@ -2555,8 +2555,11 @@ export function IntegrationsView({
|
|||
const { allProjects } = useContainerTags()
|
||||
const shortcutsConnect = useShortcutsConnect()
|
||||
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
||||
const hasProProduct =
|
||||
!publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
||||
// stay on hasProProduct. See useConnectorAccess.
|
||||
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
||||
enabled: !publicMode,
|
||||
})
|
||||
const isAutumnLoading = !publicMode && autumn.isLoading
|
||||
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
|
|
@ -2601,7 +2604,7 @@ export function IntegrationsView({
|
|||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: !publicMode && hasProProduct,
|
||||
enabled: !publicMode && connectorAccess,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
@ -2915,7 +2918,7 @@ export function IntegrationsView({
|
|||
}
|
||||
|
||||
if (target === "granola") {
|
||||
if (!hasProProduct) {
|
||||
if (!connectorAccess) {
|
||||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
|
|
@ -2939,6 +2942,7 @@ export function IntegrationsView({
|
|||
connectTarget,
|
||||
isAutumnLoading,
|
||||
hasProProduct,
|
||||
connectorAccess,
|
||||
publicMode,
|
||||
redirectToLogin,
|
||||
setConnectTarget,
|
||||
|
|
@ -3397,7 +3401,7 @@ export function IntegrationsView({
|
|||
case "connector": {
|
||||
const count = connectionsByProvider[item.provider].length
|
||||
const isGranola = item.provider === "granola"
|
||||
const needsPlanUpgrade = !isAutumnLoading && !hasProProduct
|
||||
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
|
@ -3408,7 +3412,7 @@ export function IntegrationsView({
|
|||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (isGranola) {
|
||||
if (!hasProProduct) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -3440,7 +3444,7 @@ export function IntegrationsView({
|
|||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (isGranola) {
|
||||
if (!hasProProduct) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
|
|
@ -4260,9 +4264,9 @@ export function IntegrationsView({
|
|||
</Dialog>
|
||||
|
||||
<GranolaConnectModal
|
||||
open={hasProProduct && granolaModalOpen}
|
||||
open={connectorAccess && granolaModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setGranolaModalOpen(open && hasProProduct)
|
||||
setGranolaModalOpen(open && connectorAccess)
|
||||
if (!open) void setConnectTarget(null)
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,14 +15,16 @@ export function PillButton({
|
|||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: "button" | "submit"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,697 @@
|
|||
"use client"
|
||||
|
||||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowRight, Check, Globe, Loader2 } from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type ResearchEvent,
|
||||
type ResearchStat,
|
||||
useResearchStatus,
|
||||
} from "@/hooks/use-research-status"
|
||||
import {
|
||||
cardSurfaceStyle,
|
||||
DomainLogo,
|
||||
fieldLabel,
|
||||
inputBevelStyle,
|
||||
inputClass,
|
||||
UserAvatar,
|
||||
} from "./step-about"
|
||||
import { ResearchActionRail } from "./research-action-rail"
|
||||
import {
|
||||
type CompanyBrainConfirmResult,
|
||||
workspaceNameFromDomain,
|
||||
} from "./types"
|
||||
|
||||
interface CompanyBrainOnboardingProps {
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
domain: string
|
||||
submitting: boolean
|
||||
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Phase = "confirm" | "research"
|
||||
|
||||
function normalizeDomain(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
}
|
||||
|
||||
export function CompanyBrainOnboarding({
|
||||
name,
|
||||
avatarUrl,
|
||||
domain: initialDomain,
|
||||
submitting,
|
||||
onConfirm,
|
||||
onDone,
|
||||
}: CompanyBrainOnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm")
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
||||
const clean = normalizeDomain(domain)
|
||||
const queryClient = useQueryClient()
|
||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||
const researchDone = researchStatus === "done"
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!clean || submitting) return
|
||||
const result = await onConfirm(clean)
|
||||
if (!result.ok) return
|
||||
setServerSchedulesResearch(result.serverSchedulesResearch)
|
||||
setPhase("research")
|
||||
}
|
||||
|
||||
// New-org signup schedules research after provisioning; if that hook is slow
|
||||
// or fails, force-start from the client so onboarding doesn't stall.
|
||||
useEffect(() => {
|
||||
if (phase !== "research" || !serverSchedulesResearch || !clean) return
|
||||
const timer = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/research/status`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const state = (await res.json()) as {
|
||||
status?: string | null
|
||||
events?: { aspect: string }[]
|
||||
}
|
||||
if (state.status === "done") return
|
||||
// Real research aspects use ord >= 2; bail if one is already underway.
|
||||
if ((state.events?.length ?? 0) >= 3) return
|
||||
await fetch(`${BACKEND}/brain/research/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify({ domain: clean }),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
|
||||
} catch {}
|
||||
})()
|
||||
}, 40_000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [phase, serverSchedulesResearch, clean, queryClient])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Backdrop />
|
||||
|
||||
<header className="relative z-10 flex items-center px-6 md:px-10 py-4">
|
||||
<LogoFull className="h-5 md:h-6 text-[#fafafa]" />
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"relative z-10 flex-1 flex flex-col min-h-0",
|
||||
phase === "confirm"
|
||||
? "justify-center items-center px-4 md:px-10"
|
||||
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
|
||||
)}
|
||||
>
|
||||
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 260, damping: 30 }}
|
||||
style={cardSurfaceStyle}
|
||||
className={cn(
|
||||
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
|
||||
phase === "confirm"
|
||||
? "max-w-xl p-6 md:p-8"
|
||||
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{phase === "confirm" ? (
|
||||
<motion.div
|
||||
key="confirm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ConfirmBody
|
||||
firstName={firstName}
|
||||
name={name}
|
||||
avatarUrl={avatarUrl}
|
||||
domain={domain}
|
||||
onDomainChange={setDomain}
|
||||
onConfirm={handleConfirm}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="docked"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25, delay: 0.1 }}
|
||||
>
|
||||
<DockedHeader
|
||||
domain={clean}
|
||||
done={researchDone}
|
||||
onContinue={onDone}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{phase === "confirm" && (
|
||||
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-end px-1">
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleConfirm}
|
||||
disabled={!clean || submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
Starting…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Confirm
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{phase === "research" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15, duration: 0.3 }}
|
||||
className="relative w-full max-w-7xl xl:max-w-[1360px] mx-auto flex flex-col flex-1 min-h-0 mt-4 mb-8 gap-4"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-4">
|
||||
<div className="flex min-h-[280px] min-w-0 flex-[1.15] flex-col lg:min-w-0">
|
||||
<ResearchTranscript />
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.35, duration: 0.4 }}
|
||||
className="flex min-h-[200px] min-w-0 flex-1 flex-col lg:min-w-[340px] lg:max-w-[420px]"
|
||||
>
|
||||
<ResearchActionRail domain={clean} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
firstName,
|
||||
name,
|
||||
avatarUrl,
|
||||
domain,
|
||||
onDomainChange,
|
||||
onConfirm,
|
||||
submitting,
|
||||
}: {
|
||||
firstName: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
domain: string
|
||||
onDomainChange: (v: string) => void
|
||||
onConfirm: () => void
|
||||
submitting: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<UserAvatar url={avatarUrl} name={name} className="size-12 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{firstName ? `Hey ${firstName} 👋` : "Hey there 👋"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
|
||||
I'll research your company and set up its Brain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className={fieldLabel}>Company domain</p>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<DomainLogo domain={normalizeDomain(domain) || "supermemory.ai"} />
|
||||
</div>
|
||||
<Input
|
||||
value={domain}
|
||||
onChange={(e) => onDomainChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !submitting) onConfirm()
|
||||
}}
|
||||
placeholder="your-team.com"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={cn(inputClass, "pl-14")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DockedHeader({
|
||||
domain,
|
||||
done,
|
||||
onContinue,
|
||||
}: {
|
||||
domain: string
|
||||
done: boolean
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const brandName = workspaceNameFromDomain(domain) || domain
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<DomainLogo domain={domain} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-[#fafafa]">
|
||||
{brandName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-medium",
|
||||
done ? "text-[#5CD68A]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? "Company Brain ready" : "Building your Company Brain…"}
|
||||
</span>
|
||||
{done ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className={cn(
|
||||
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResearchTranscript() {
|
||||
const { status, events } = useResearchStatus()
|
||||
const running = status !== "done"
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, [events.length])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<div
|
||||
style={cardSurfaceStyle}
|
||||
className="relative flex min-h-0 flex-col overflow-hidden rounded-[22px] bg-[#1B1F24]"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto px-6 pt-8 pb-8"
|
||||
>
|
||||
<Timeline events={events} running={running} />
|
||||
</div>
|
||||
{/* Top-only overlay in the card color: text slides up under a solid edge. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-9 rounded-t-[22px]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, #1B1F24 0%, rgba(27,31,36,0) 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
events,
|
||||
running,
|
||||
}: {
|
||||
events: ResearchEvent[]
|
||||
running: boolean
|
||||
}) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-[13px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-4 animate-spin text-[#4BA0FA]" />
|
||||
Starting deep research…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-0">
|
||||
<AnimatePresence initial={false}>
|
||||
{events.map((e, i) => {
|
||||
const isLast = i === events.length - 1
|
||||
const active = running && e.status === "in_progress"
|
||||
return (
|
||||
<motion.li
|
||||
key={e.aspect}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative flex gap-3.5 pb-6 last:pb-0"
|
||||
>
|
||||
<div className="relative flex w-3.5 shrink-0 justify-center">
|
||||
<EventDot status={e.status} active={active} />
|
||||
{!isLast && (
|
||||
<span className="absolute left-1/2 top-[19px] -bottom-[18px] w-px -translate-x-1/2 bg-[#2E353D]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 -mt-px">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[14px] font-medium leading-[14px]",
|
||||
e.status === "error"
|
||||
? "text-[#E5735A]"
|
||||
: active
|
||||
? "text-[#fafafa]"
|
||||
: "text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{e.label}
|
||||
</span>
|
||||
{e.detail && e.status !== "in_progress" && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-[12.5px] leading-[1.6] text-[#737373]"
|
||||
>
|
||||
<CitedText text={e.detail} />
|
||||
</motion.p>
|
||||
)}
|
||||
{e.status === "complete" && e.stats.length > 0 && (
|
||||
<StatStrip stats={e.stats} />
|
||||
)}
|
||||
{e.status === "complete" && e.highlights.length > 0 && (
|
||||
<Chips items={e.highlights} />
|
||||
)}
|
||||
{e.status === "complete" && e.sources.length > 0 && (
|
||||
<SourceChips urls={e.sources} />
|
||||
)}
|
||||
{active && <ThinkingLine />}
|
||||
</div>
|
||||
</motion.li>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them
|
||||
// as compact superscript links and collapse any leftover `[n]` bare markers.
|
||||
const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g
|
||||
|
||||
function CitedText({ text }: { text: string }) {
|
||||
const nodes: ReactNode[] = []
|
||||
let last = 0
|
||||
let m: RegExpExecArray | null
|
||||
CITE_RE.lastIndex = 0
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: regex walk
|
||||
while ((m = CITE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
nodes.push(cleanBareCites(text.slice(last, m.index)))
|
||||
}
|
||||
nodes.push(
|
||||
<a
|
||||
key={`${m.index}-${m[1]}`}
|
||||
href={m[2]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-px align-super text-[9px] font-semibold text-[#4BA0FA] hover:underline"
|
||||
>
|
||||
{m[1]}
|
||||
</a>,
|
||||
)
|
||||
last = m.index + m[0].length
|
||||
}
|
||||
if (last < text.length) nodes.push(cleanBareCites(text.slice(last)))
|
||||
return <>{nodes}</>
|
||||
}
|
||||
|
||||
function cleanBareCites(s: string): string {
|
||||
return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ")
|
||||
}
|
||||
|
||||
const THINKING_PHRASES = [
|
||||
"Searching the web…",
|
||||
"Reading sources…",
|
||||
"Cross-checking facts…",
|
||||
"Summarizing findings…",
|
||||
]
|
||||
|
||||
function ThinkingLine() {
|
||||
const [i, setI] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(
|
||||
() => setI((v) => (v + 1) % THINKING_PHRASES.length),
|
||||
1600,
|
||||
)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
return (
|
||||
<motion.p
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-[12px] leading-[1.5] text-[#525D6E]"
|
||||
>
|
||||
{THINKING_PHRASES[i]}
|
||||
</motion.p>
|
||||
)
|
||||
}
|
||||
|
||||
// Boxless stat strip: values over tiny labels, split by hairline dividers.
|
||||
function StatStrip({ stats }: { stats: ResearchStat[] }) {
|
||||
const shown = stats.slice(0, 3)
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-2.5 flex flex-wrap items-stretch gap-x-5 gap-y-3"
|
||||
>
|
||||
{shown.map((s, i) => (
|
||||
<div
|
||||
key={`${s.label}-${s.value}`}
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
i > 0 && "border-l border-[rgba(82,89,102,0.2)] pl-5",
|
||||
)}
|
||||
>
|
||||
<span className="text-[15px] font-semibold leading-none text-[#fafafa]">
|
||||
{s.value}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Quiet outline chips, short entities only (no sentences).
|
||||
function Chips({ items }: { items: string[] }) {
|
||||
const shown = items.filter((t) => t.length <= 42).slice(0, 4)
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-2.5 flex flex-wrap gap-1.5"
|
||||
>
|
||||
{shown.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded-md border border-[rgba(82,89,102,0.22)] px-2 py-[3px] text-[11px] font-medium text-[#8B8B8B]"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function hostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "")
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
|
||||
}
|
||||
}
|
||||
|
||||
function SourceFavicon({ host }: { host: string }) {
|
||||
const sources = [
|
||||
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`,
|
||||
`https://icons.duckduckgo.com/ip3/${host}.ico`,
|
||||
]
|
||||
const [idx, setIdx] = useState(0)
|
||||
if (idx >= sources.length) {
|
||||
return <Globe className="size-3 text-[#525D6E]" />
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={sources[idx]}
|
||||
alt=""
|
||||
className="size-4 rounded-[3px] object-contain"
|
||||
onError={() => setIdx((i) => i + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Minimal, deduped source row: favicon + muted domain, no boxes.
|
||||
function SourceChips({ urls }: { urls: string[] }) {
|
||||
const seen = new Set<string>()
|
||||
const hosts: { host: string; url: string }[] = []
|
||||
for (const url of urls) {
|
||||
const host = hostname(url)
|
||||
if (seen.has(host)) continue
|
||||
seen.add(host)
|
||||
hosts.push({ host, url })
|
||||
}
|
||||
const shown = hosts.slice(0, 4)
|
||||
const extra = hosts.length - shown.length
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-3 flex flex-wrap items-center gap-x-3.5 gap-y-1.5"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
Sources
|
||||
</span>
|
||||
{shown.map(({ host, url }) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<SourceFavicon host={host} />
|
||||
</span>
|
||||
{host}
|
||||
</a>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<span className="text-[11px] font-medium text-[#525D6E]">+{extra}</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// All dots render in a 14px box so their centers align regardless of shape.
|
||||
function EventDot({ status, active }: { status: string; active: boolean }) {
|
||||
if (active) {
|
||||
return <Loader2 className="size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
}
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
|
||||
<Check className="size-2.5 text-[#05080D]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2.5 rounded-full",
|
||||
status === "error" ? "bg-[#E5735A]" : "bg-[#4BA0FA]/60",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Backdrop() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 select-none"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 select-none"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
maskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
||||
WebkitMaskImage:
|
||||
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
777
apps/web/components/onboarding-brain/research-action-rail.tsx
Normal file
777
apps/web/components/onboarding-brain/research-action-rail.tsx
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ArrowRight, Check, Loader2, Mail, Plus, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
brainConnectorIcon,
|
||||
SlackMark,
|
||||
} from "@/components/brain-connector-icons"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useResearchStatus } from "@/hooks/use-research-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
|
||||
const SETUP_STEPS = ["slack", "apps", "invite"] as const
|
||||
type SetupStepId = (typeof SETUP_STEPS)[number]
|
||||
|
||||
const ROTATE_MS = 35_000
|
||||
const REVEAL_DELAY_MS = 1_800
|
||||
|
||||
const tileStyle = {
|
||||
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)",
|
||||
}
|
||||
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
|
||||
|
||||
type AuthType = "oauth" | "static" | "none"
|
||||
type CatalogEntry = {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
authType: AuthType
|
||||
tokenHint?: string
|
||||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
export type ParallelSetupStats = {
|
||||
slackConnected: boolean
|
||||
appsConnected: number
|
||||
invitesSent: number
|
||||
}
|
||||
|
||||
const STEP_META: Record<SetupStepId, { label: string; hint: string }> = {
|
||||
slack: {
|
||||
label: "Add to Slack",
|
||||
hint: "Ask your company brain in-channel.",
|
||||
},
|
||||
apps: {
|
||||
label: "Connect apps",
|
||||
hint: "",
|
||||
},
|
||||
invite: {
|
||||
label: "Invite teammates",
|
||||
hint: "Multiply what the brain remembers.",
|
||||
},
|
||||
}
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function ResearchActionRail({
|
||||
domain,
|
||||
onStatsChange,
|
||||
}: {
|
||||
domain: string
|
||||
onStatsChange?: (stats: ParallelSetupStats) => void
|
||||
}) {
|
||||
const { org } = useAuth()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { events } = useResearchStatus()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slack, setSlack] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [invites, setInvites] = useState<{ email: string }[]>([])
|
||||
const [draft, setDraft] = useState("")
|
||||
const [invitesSent, setInvitesSent] = useState(0)
|
||||
const [sendingInvites, setSendingInvites] = useState(false)
|
||||
const [spotlight, setSpotlight] = useState<SetupStepId>("slack")
|
||||
const [rotationPaused, setRotationPaused] = useState(false)
|
||||
const [minDelayPassed, setMinDelayPassed] = useState(false)
|
||||
const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const domainOrFallback = (domain || "your-team.com").trim().toLowerCase()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [cat, conn, s] = await Promise.all([
|
||||
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
||||
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
try {
|
||||
if (cat.ok) {
|
||||
const data: { catalog?: CatalogEntry[] } = await cat.json()
|
||||
setCatalog(data.catalog ?? [])
|
||||
} else setCatalog([])
|
||||
} catch {
|
||||
setCatalog([])
|
||||
}
|
||||
try {
|
||||
if (conn.ok) {
|
||||
const data: { connections?: ConnRow[] } = await conn.json()
|
||||
setRows(data.connections ?? [])
|
||||
} else setRows([])
|
||||
} catch {
|
||||
setRows([])
|
||||
}
|
||||
try {
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
} catch {
|
||||
setCatalog([])
|
||||
setRows([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const onFocus = () => void load()
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setMinDelayPassed(true), REVEAL_DELAY_MS)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
const isConnected = useCallback(
|
||||
(slug: string) =>
|
||||
rows.some((r) => r.serverSlug === slug && r.status === "active"),
|
||||
[rows],
|
||||
)
|
||||
|
||||
const apps = catalog ?? []
|
||||
const featured = FEATURED_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const appsConnected = apps.filter((a) => isConnected(a.slug)).length
|
||||
const slackConnected = slack?.connected ?? false
|
||||
|
||||
const stepDone = useCallback(
|
||||
(id: SetupStepId) => {
|
||||
if (id === "slack") return slackConnected
|
||||
if (id === "apps") return appsConnected > 0
|
||||
return invitesSent > 0
|
||||
},
|
||||
[slackConnected, appsConnected, invitesSent],
|
||||
)
|
||||
|
||||
const incompleteSteps = useMemo(
|
||||
() => SETUP_STEPS.filter((id) => !stepDone(id)),
|
||||
[stepDone],
|
||||
)
|
||||
|
||||
const pauseRotation = useCallback((ms = 60_000) => {
|
||||
setRotationPaused(true)
|
||||
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
|
||||
pauseTimerRef.current = setTimeout(() => setRotationPaused(false), ms)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (rotationPaused || incompleteSteps.length === 0) return
|
||||
if (!incompleteSteps.includes(spotlight)) {
|
||||
setSpotlight(incompleteSteps[0] ?? "slack")
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setSpotlight((prev) => {
|
||||
const pool = SETUP_STEPS.filter((id) => !stepDone(id))
|
||||
if (pool.length === 0) return prev
|
||||
const idx = pool.indexOf(prev)
|
||||
return pool[(idx + 1) % pool.length] ?? pool[0] ?? "slack"
|
||||
})
|
||||
}, ROTATE_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [rotationPaused, incompleteSteps, spotlight, stepDone])
|
||||
|
||||
useEffect(() => {
|
||||
onStatsChange?.({
|
||||
slackConnected,
|
||||
appsConnected,
|
||||
invitesSent,
|
||||
})
|
||||
}, [slackConnected, appsConnected, invitesSent, onStatsChange])
|
||||
|
||||
const setupBeatDone = events.some(
|
||||
(e) => e.aspect === "prepare" && e.status === "complete",
|
||||
)
|
||||
const revealed = minDelayPassed && (setupBeatDone || events.length >= 2)
|
||||
|
||||
const focusStep = (id: SetupStepId) => {
|
||||
setSpotlight(id)
|
||||
pauseRotation()
|
||||
}
|
||||
|
||||
const connect = async (entry: CatalogEntry) => {
|
||||
pauseRotation()
|
||||
setBusy(entry.slug)
|
||||
try {
|
||||
if (entry.authType === "static") {
|
||||
const token = window.prompt(
|
||||
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
||||
)
|
||||
if (!token) return
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, shared: false }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't connect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
shared: false,
|
||||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data: { authUrl?: string; ok?: boolean } = await res.json()
|
||||
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
|
||||
else if (data.ok) {
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
} else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const addInvites = (text: string) => {
|
||||
const found = text.match(EMAIL_RE) ?? []
|
||||
if (found.length === 0) return
|
||||
const existing = new Set(invites.map((i) => i.email.toLowerCase()))
|
||||
const next: { email: string }[] = []
|
||||
for (const raw of found) {
|
||||
const email = raw.trim().toLowerCase()
|
||||
if (!email || existing.has(email)) continue
|
||||
existing.add(email)
|
||||
next.push({ email })
|
||||
}
|
||||
if (next.length === 0) {
|
||||
setDraft("")
|
||||
return
|
||||
}
|
||||
setInvites((prev) => [...prev, ...next])
|
||||
setDraft("")
|
||||
}
|
||||
|
||||
const sendInvites = async () => {
|
||||
if (sendingInvites || invites.length === 0) return
|
||||
if (!org?.id) {
|
||||
toast.error("Organization isn't ready yet.")
|
||||
return
|
||||
}
|
||||
setSendingInvites(true)
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
invites.map((inv) =>
|
||||
authClient.organization.inviteMember({
|
||||
email: inv.email,
|
||||
role: "member",
|
||||
organizationId: org.id,
|
||||
resend: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const failed = results.filter(
|
||||
(r) =>
|
||||
r.status === "rejected" ||
|
||||
(r.status === "fulfilled" && Boolean(r.value?.error)),
|
||||
).length
|
||||
const sent = invites.length - failed
|
||||
if (sent > 0) {
|
||||
setInvitesSent((n) => n + sent)
|
||||
setInvites([])
|
||||
toast.success(
|
||||
`Sent ${sent} invite${sent === 1 ? "" : "s"} to your team.`,
|
||||
)
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
`${failed} invite${failed === 1 ? "" : "s"} couldn't be sent.`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Couldn't send invites. Try again in a moment.")
|
||||
} finally {
|
||||
setSendingInvites(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doneSummary = (id: SetupStepId) => {
|
||||
if (id === "slack") return slack?.teamName ?? "Connected"
|
||||
if (id === "apps") return `${appsConnected} connected`
|
||||
return `${invitesSent} sent`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cardSurfaceStyle}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-[#161A20]/95"
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] font-medium text-[#737373]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
While we research
|
||||
</p>
|
||||
|
||||
{!revealed ? (
|
||||
<ol className="mt-5 flex flex-col opacity-40">
|
||||
{SETUP_STEPS.map((id, i) => {
|
||||
const isLast = i === SETUP_STEPS.length - 1
|
||||
const meta = STEP_META[id]
|
||||
return (
|
||||
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
|
||||
<div className="relative flex w-3.5 shrink-0 justify-center">
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
||||
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
|
||||
</span>
|
||||
{!isLast && (
|
||||
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-[#525D6E]">
|
||||
{meta.label}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<ol className="mt-5 flex flex-col">
|
||||
{SETUP_STEPS.map((id, i) => {
|
||||
const done = stepDone(id)
|
||||
const active = !done && spotlight === id
|
||||
const isLast = i === SETUP_STEPS.length - 1
|
||||
const meta = STEP_META[id]
|
||||
|
||||
return (
|
||||
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
|
||||
<div className="relative flex w-3.5 shrink-0 justify-center">
|
||||
<SetupDot done={done} active={active} />
|
||||
{!isLast && (
|
||||
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 -mt-px">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => focusStep(id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[13px] font-medium leading-[14px] transition-colors",
|
||||
active && "text-[#FAFAFA]",
|
||||
done && "text-[#525D6E]",
|
||||
!active &&
|
||||
!done &&
|
||||
"text-[#737373] hover:text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{done && (
|
||||
<p className="mt-1 text-[12px] font-medium text-[#525D6E]">
|
||||
{doneSummary(id)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{active && (
|
||||
<motion.div
|
||||
key={`${id}-body`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="mt-3"
|
||||
>
|
||||
{meta.hint ? (
|
||||
<p className="text-[12px] font-medium leading-[1.5] text-[#525D6E]">
|
||||
{meta.hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={cn(meta.hint ? "mt-3" : "mt-2")}>
|
||||
{id === "slack" && (
|
||||
<SlackStepBody
|
||||
connected={slackConnected}
|
||||
teamName={slack?.teamName ?? null}
|
||||
/>
|
||||
)}
|
||||
{id === "apps" && (
|
||||
<AppsStepBody
|
||||
catalog={catalog}
|
||||
featured={featured}
|
||||
isConnected={isConnected}
|
||||
busy={busy}
|
||||
onConnect={connect}
|
||||
onBrowse={() => {
|
||||
pauseRotation()
|
||||
openSettings("company-brain")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{id === "invite" && (
|
||||
<InviteStepBody
|
||||
domain={domainOrFallback}
|
||||
draft={draft}
|
||||
invites={invites}
|
||||
invitesSent={invitesSent}
|
||||
sendingInvites={sendingInvites}
|
||||
onDraftChange={setDraft}
|
||||
onAddInvites={addInvites}
|
||||
onRemoveInvite={(email) =>
|
||||
setInvites((prev) =>
|
||||
prev.filter((i) => i.email !== email),
|
||||
)
|
||||
}
|
||||
onSendInvites={sendInvites}
|
||||
onFocus={() => pauseRotation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupDot({ done, active }: { done: boolean; active: boolean }) {
|
||||
if (done) {
|
||||
return (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
|
||||
<Check className="size-2.5 text-[#05080D]" strokeWidth={3} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (active) {
|
||||
return (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
||||
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
||||
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackStepBody({
|
||||
connected,
|
||||
teamName,
|
||||
}: {
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
}) {
|
||||
if (connected) {
|
||||
return (
|
||||
<p className="text-[12px] font-medium text-[#525D6E]">
|
||||
Live in {teamName ?? "your workspace"}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function AppsStepBody({
|
||||
catalog,
|
||||
featured,
|
||||
isConnected,
|
||||
busy,
|
||||
onConnect,
|
||||
onBrowse,
|
||||
}: {
|
||||
catalog: CatalogEntry[] | null
|
||||
featured: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
busy: string | null
|
||||
onConnect: (entry: CatalogEntry) => void
|
||||
onBrowse: () => void
|
||||
}) {
|
||||
if (catalog === null) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex h-12 items-center gap-3 px-3",
|
||||
i < 2 && "border-b border-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
<div className="size-8 animate-pulse rounded-[8px] bg-[#1c2128]" />
|
||||
<div className="h-2.5 flex-1 animate-pulse rounded bg-[#1c2128]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (featured.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A1A1AA]"
|
||||
>
|
||||
Browse connections →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const allConnected = featured.every((e) => isConnected(e.slug))
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div
|
||||
className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-4")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => onConnect(entry)}
|
||||
showDivider={i < featured.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="text-[12px] font-medium text-[#525D6E] transition-colors hover:text-[#A1A1AA]"
|
||||
>
|
||||
{allConnected ? "Browse more connections →" : "More connections →"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteStepBody({
|
||||
domain,
|
||||
draft,
|
||||
invites,
|
||||
invitesSent,
|
||||
sendingInvites,
|
||||
onDraftChange,
|
||||
onAddInvites,
|
||||
onRemoveInvite,
|
||||
onSendInvites,
|
||||
onFocus,
|
||||
}: {
|
||||
domain: string
|
||||
draft: string
|
||||
invites: { email: string }[]
|
||||
invitesSent: number
|
||||
sendingInvites: boolean
|
||||
onDraftChange: (v: string) => void
|
||||
onAddInvites: (text: string) => void
|
||||
onRemoveInvite: (email: string) => void
|
||||
onSendInvites: () => void
|
||||
onFocus: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onAddInvites(draft)
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Mail className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-[#525D6E]" />
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onPaste={(e) => {
|
||||
const pasted = e.clipboardData.getData("text")
|
||||
if (pasted && EMAIL_RE.test(pasted)) {
|
||||
e.preventDefault()
|
||||
onAddInvites(pasted)
|
||||
}
|
||||
}}
|
||||
placeholder={`teammate@${domain}`}
|
||||
className={cn(inputClass, "h-10 pl-9 text-[13px]")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="insideOut"
|
||||
disabled={!draft.trim()}
|
||||
className="rounded-full size-10 p-0 text-[#fafafa] shrink-0"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
{invites.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{invites.map((inv) => (
|
||||
<div key={inv.email} className="flex items-center gap-2 py-1">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#737373]">
|
||||
{inv.email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveInvite(inv.email)}
|
||||
className="text-[#525D6E] hover:text-[#A1A1AA] p-0.5"
|
||||
aria-label={`Remove ${inv.email}`}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onSendInvites}
|
||||
disabled={sendingInvites}
|
||||
className="w-full rounded-full h-9 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{sendingInvites ? (
|
||||
<>
|
||||
Sending…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Send {invites.length} invite
|
||||
{invites.length === 1 ? "" : "s"}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{invitesSent > 0 && invites.length === 0 && (
|
||||
<p className="text-[12px] font-medium text-[#525D6E]">
|
||||
{invitesSent} invite{invitesSent === 1 ? "" : "s"} sent
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppTile({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
connected,
|
||||
busy,
|
||||
onConnect,
|
||||
showDivider = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
connected: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[48px] items-center gap-2.5 px-3 py-2",
|
||||
showDivider && "border-b border-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-semibold leading-none text-[#E4E4E7]">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] font-medium text-[#525D6E]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<Check className="size-3.5 shrink-0 text-[#4BA0FA]" strokeWidth={2.5} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.06] px-3 py-1 text-[11px] font-medium text-[#FAFAFA] transition-opacity hover:bg-white/[0.1] disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : "Connect"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { Textarea } from "@ui/components/textarea"
|
||||
|
|
@ -14,14 +14,17 @@ import {
|
|||
Mail,
|
||||
Plug,
|
||||
Terminal,
|
||||
User2,
|
||||
UserPlus,
|
||||
Users2,
|
||||
Wand2,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import type { BrainMode } from "./types"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
export interface AboutValues {
|
||||
name: string
|
||||
about: string
|
||||
|
|
@ -43,18 +46,18 @@ interface Props {
|
|||
submitting?: boolean
|
||||
}
|
||||
|
||||
const cardSurfaceStyle = {
|
||||
export const cardSurfaceStyle = {
|
||||
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 = {
|
||||
export 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)",
|
||||
}
|
||||
|
||||
const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
|
||||
const inputClass =
|
||||
export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
|
||||
export const inputClass =
|
||||
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
|
||||
|
||||
export function StepAbout({
|
||||
|
|
@ -84,36 +87,115 @@ export function StepAbout({
|
|||
}, [defaultName, suggestedWorkspaceName, domain])
|
||||
|
||||
const teamGated = mode === "team" && !allowTeam
|
||||
const isTeam = mode === "team" && !teamGated
|
||||
|
||||
// Default the workspace name per mode: name-derived for Personal (field hidden),
|
||||
// email-derived for Team unless the user has typed their own.
|
||||
const workspaceTouched = useRef(false)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only
|
||||
useEffect(() => {
|
||||
if (mode === "personal") {
|
||||
const first = values.name.trim().split(/\s+/)[0]
|
||||
const derived = first
|
||||
? `${first}'s Brain`
|
||||
: suggestedWorkspaceName || "My brain"
|
||||
if (values.workspaceName !== derived) {
|
||||
onChange({ ...values, workspaceName: derived })
|
||||
}
|
||||
} else if (!workspaceTouched.current) {
|
||||
const derived = suggestedWorkspaceName || ""
|
||||
if (values.workspaceName !== derived) {
|
||||
onChange({ ...values, workspaceName: derived })
|
||||
}
|
||||
}
|
||||
}, [mode, values.name, suggestedWorkspaceName])
|
||||
|
||||
// Auto-draft the "what does your company do" blurb from the domain.
|
||||
const valuesRef = useRef(values)
|
||||
valuesRef.current = values
|
||||
const aboutTouched = useRef(false)
|
||||
const summarizedDomain = useRef<string | null>(null)
|
||||
const latestDraftDomain = useRef<string | null>(null)
|
||||
const [drafting, setDrafting] = useState(false)
|
||||
const [drafted, setDrafted] = useState(false)
|
||||
|
||||
const draftCompany = async (rawDomain: string) => {
|
||||
const d = rawDomain.trim().toLowerCase()
|
||||
if (!d || summarizedDomain.current === d) return
|
||||
if (aboutTouched.current && valuesRef.current.about.trim()) return
|
||||
summarizedDomain.current = d
|
||||
latestDraftDomain.current = d
|
||||
setDrafting(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/company-summary`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ domain: d }),
|
||||
})
|
||||
// Ignore a stale response if the domain changed mid-flight.
|
||||
if (latestDraftDomain.current !== d) return
|
||||
if (!res.ok) return
|
||||
const data = (await res.json()) as { summary?: string | null }
|
||||
if (
|
||||
data.summary &&
|
||||
!aboutTouched.current &&
|
||||
latestDraftDomain.current === d
|
||||
) {
|
||||
onChange({ ...valuesRef.current, about: data.summary })
|
||||
setDrafted(true)
|
||||
}
|
||||
} catch {
|
||||
if (latestDraftDomain.current === d) summarizedDomain.current = null
|
||||
} finally {
|
||||
if (latestDraftDomain.current === d) setDrafting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Draft once when entering Team with a domain already filled (e.g. from email).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only
|
||||
useEffect(() => {
|
||||
if (!isTeam) return
|
||||
const d = (values.workspaceDomain || domain || "").trim()
|
||||
if (d && !values.about.trim()) draftCompany(d)
|
||||
}, [isTeam])
|
||||
|
||||
const canContinue =
|
||||
!teamGated &&
|
||||
values.name.trim().length > 0 &&
|
||||
values.workspaceName.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid md:grid-cols-[1.6fr_1fr] gap-4 items-stretch">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<div className="max-w-xl mx-auto space-y-5">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
|
||||
<div className="mt-7 flex items-center gap-4">
|
||||
<UserAvatar
|
||||
url={avatarUrl}
|
||||
name={values.name || defaultName}
|
||||
className="size-14 mb-5"
|
||||
className="size-12 shrink-0"
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Tell us about you
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
So your brain sounds like yours, not the docs.
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Tell us about you
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
|
||||
So your brain sounds like yours, not the docs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className={cn("grid gap-4", isTeam && "sm:grid-cols-2")}>
|
||||
<div>
|
||||
<p className={fieldLabel}>Your name</p>
|
||||
<Input
|
||||
|
|
@ -125,54 +207,147 @@ export function StepAbout({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isTeam && (
|
||||
<div>
|
||||
<p className={fieldLabel}>Workspace name</p>
|
||||
<Input
|
||||
value={values.workspaceName}
|
||||
onChange={(e) => {
|
||||
workspaceTouched.current = true
|
||||
onChange({ ...values, workspaceName: e.target.value })
|
||||
}}
|
||||
placeholder={suggestedWorkspaceName || "Acme"}
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Company domain slides in only for Team mode. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isTeam && (
|
||||
<motion.div
|
||||
key="domain"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className={fieldLabel}>Company domain</p>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{values.workspaceDomain || domain ? (
|
||||
<DomainLogo
|
||||
domain={values.workspaceDomain || domain || ""}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={values.workspaceDomain}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, workspaceDomain: e.target.value })
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const d = e.target.value.trim()
|
||||
if (d !== values.workspaceDomain) {
|
||||
onChange({ ...values, workspaceDomain: d })
|
||||
}
|
||||
draftCompany(d)
|
||||
}}
|
||||
placeholder="your-team.com"
|
||||
className={cn(inputClass, "pl-14")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!teamGated && (
|
||||
<div>
|
||||
<p className={fieldLabel}>
|
||||
What are you here for?{" "}
|
||||
<span className="text-[#525D6E] font-medium">(optional)</span>
|
||||
</p>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 pl-2">
|
||||
<p className="font-semibold text-[14px] text-[#737373]">
|
||||
{isTeam ? (
|
||||
"What does your company do?"
|
||||
) : (
|
||||
<>
|
||||
What are you here for?{" "}
|
||||
<span className="text-[#525D6E] font-medium">
|
||||
(optional)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isTeam &&
|
||||
(drafting ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Drafting from your site…
|
||||
</span>
|
||||
) : drafted ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#525D6E]">
|
||||
<Wand2 className="size-3" />
|
||||
Drafted from your site · edit anything
|
||||
</span>
|
||||
) : null)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={values.about}
|
||||
onChange={(e) => onChange({ ...values, about: e.target.value })}
|
||||
placeholder="A sentence or two — what you do, what you're hoping the brain helps with."
|
||||
rows={4}
|
||||
value={drafting ? "" : values.about}
|
||||
onChange={(e) => {
|
||||
aboutTouched.current = true
|
||||
setDrafted(false)
|
||||
onChange({ ...values, about: e.target.value })
|
||||
}}
|
||||
placeholder={
|
||||
drafting
|
||||
? ""
|
||||
: isTeam
|
||||
? "A sentence or two — what your company builds, who you serve, how the team works."
|
||||
: "A sentence or two — what you do, what you're hoping the brain helps with."
|
||||
}
|
||||
rows={3}
|
||||
disabled={drafting}
|
||||
className={cn(
|
||||
inputClass,
|
||||
"h-auto resize-none py-3 leading-[1.5]",
|
||||
drafting && "opacity-60",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7 flex flex-col"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
|
||||
<div className="mt-6">
|
||||
{teamGated ? (
|
||||
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
|
||||
) : mode === "team" ? (
|
||||
<TeamWorkspaceCard
|
||||
domain={values.workspaceDomain || domain || ""}
|
||||
onDomainChange={(d) =>
|
||||
onChange({ ...values, workspaceDomain: d })
|
||||
}
|
||||
value={values.workspaceName}
|
||||
onChange={(w) => onChange({ ...values, workspaceName: w })}
|
||||
suggested={suggestedWorkspaceName}
|
||||
/>
|
||||
) : (
|
||||
<PersonalWorkspaceCard
|
||||
value={values.workspaceName}
|
||||
onChange={(w) => onChange({ ...values, workspaceName: w })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{teamGated ? (
|
||||
<motion.div
|
||||
key="gate"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="mt-6"
|
||||
>
|
||||
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`perks-${mode}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<PerksFooter
|
||||
perks={mode === "team" ? TEAM_PERKS : PERSONAL_PERKS}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px] px-1">
|
||||
<Button
|
||||
|
|
@ -198,90 +373,43 @@ export function StepAbout({
|
|||
)
|
||||
}
|
||||
|
||||
function TeamWorkspaceCard({
|
||||
domain,
|
||||
onDomainChange,
|
||||
value,
|
||||
onChange,
|
||||
suggested,
|
||||
}: {
|
||||
domain: string
|
||||
onDomainChange: (d: string) => void
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
suggested: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{domain ? (
|
||||
<DomainLogo domain={domain} />
|
||||
) : (
|
||||
<Building2 className="size-5 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={domain}
|
||||
onChange={(e) => onDomainChange(e.target.value.trim())}
|
||||
placeholder="your-team.com"
|
||||
className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
|
||||
/>
|
||||
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
Team workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const PERSONAL_PERKS: Perk[] = [
|
||||
{
|
||||
icon: <Brain className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Your own brain",
|
||||
blurb: "Notes, docs, bookmarks — all searchable in one place.",
|
||||
},
|
||||
{
|
||||
icon: <Plug className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Plug into your AI tools",
|
||||
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
|
||||
},
|
||||
{
|
||||
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Switch to a team anytime",
|
||||
blurb: "Invite teammates whenever you're ready.",
|
||||
},
|
||||
]
|
||||
|
||||
<div className="mt-6">
|
||||
<p className={fieldLabel}>
|
||||
Workspace name{" "}
|
||||
<span className="text-[#525D6E] font-medium">
|
||||
(rename if you'd like)
|
||||
</span>
|
||||
</p>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={suggested || "Acme"}
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
const TEAM_PERKS: Perk[] = [
|
||||
{
|
||||
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Invite teammates",
|
||||
blurb: "Everyone contributes to the same brain.",
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Shared coding agent context",
|
||||
blurb: "Claude, Cursor, MCP — same brain across the team.",
|
||||
},
|
||||
{
|
||||
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Org-wide spaces",
|
||||
blurb: "Carve out sales, eng, design with their own access.",
|
||||
},
|
||||
]
|
||||
|
||||
<PerksList
|
||||
heading="What this unlocks"
|
||||
perks={[
|
||||
{
|
||||
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Invite teammates",
|
||||
blurb: "Everyone contributes to the same brain.",
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Shared coding agent context",
|
||||
blurb: "Claude, Cursor, MCP — same brain across the team.",
|
||||
},
|
||||
{
|
||||
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Org-wide spaces",
|
||||
blurb: "Carve out sales, eng, design with their own access.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
|
||||
Not your team? Switch above.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainLogo({ domain }: { domain: string }) {
|
||||
export function DomainLogo({ domain }: { domain: string }) {
|
||||
const sources = [
|
||||
`https://logo.clearbit.com/${domain}`,
|
||||
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
|
||||
|
|
@ -301,71 +429,6 @@ function DomainLogo({ domain }: { domain: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function PersonalWorkspaceCard({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<User2 className="size-5 text-[#737373]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[18px] text-[#fafafa] font-semibold">
|
||||
Just you, for now
|
||||
</div>
|
||||
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
Personal workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className={fieldLabel}>Workspace nickname</p>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="My brain"
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PerksList
|
||||
heading="What this unlocks"
|
||||
perks={[
|
||||
{
|
||||
icon: <Brain className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Your own brain",
|
||||
blurb: "Notes, docs, bookmarks — all searchable in one place.",
|
||||
},
|
||||
{
|
||||
icon: <Plug className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Plug into your AI tools",
|
||||
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
|
||||
},
|
||||
{
|
||||
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
|
||||
title: "Switch to a team anytime",
|
||||
blurb: "Invite teammates whenever you're ready.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
|
||||
Working with a team? Switch above.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -435,6 +498,13 @@ function ModeToggle({
|
|||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
|
||||
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
|
||||
animate={{ x: mode === "team" ? "100%" : "0%" }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
|
||||
/>
|
||||
{items.map((item) => {
|
||||
const isActive = mode === item.id
|
||||
return (
|
||||
|
|
@ -443,28 +513,13 @@ function ModeToggle({
|
|||
type="button"
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cn(
|
||||
"relative h-8 rounded-full text-center transition-colors",
|
||||
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
|
||||
isActive
|
||||
? "text-[#fafafa]"
|
||||
: "text-[#737373] hover:text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="brain-mode-pill"
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: "#00173C",
|
||||
border: "1px solid #2261CA66",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 34,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -472,7 +527,7 @@ function ModeToggle({
|
|||
)
|
||||
}
|
||||
|
||||
function UserAvatar({
|
||||
export function UserAvatar({
|
||||
url,
|
||||
name,
|
||||
className,
|
||||
|
|
@ -511,22 +566,18 @@ function UserAvatar({
|
|||
|
||||
type Perk = { icon: React.ReactNode; title: string; blurb: string }
|
||||
|
||||
function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
|
||||
function PerksFooter({ perks }: { perks: Perk[] }) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold mb-3 pl-1">
|
||||
{heading}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{perks.map((p) => (
|
||||
<li key={p.title} className="flex items-center gap-2.5 pl-1">
|
||||
<span className="shrink-0">{p.icon}</span>
|
||||
<span className="text-[13px] text-[#fafafa] font-medium">
|
||||
{p.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
|
||||
{perks.map((p) => (
|
||||
<span
|
||||
key={p.title}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] text-[#A1A1AA] font-medium"
|
||||
>
|
||||
<span className="shrink-0">{p.icon}</span>
|
||||
{p.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ type FlowTool = {
|
|||
}
|
||||
|
||||
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
||||
// Team/company-brain onboarding is focused: just get Supermemory into Slack.
|
||||
// Coding agents live under "More tools (full catalog)".
|
||||
team: [
|
||||
{
|
||||
id: "slack",
|
||||
|
|
@ -46,20 +48,6 @@ const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
|||
kind: "slack",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Shared context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
],
|
||||
personal: [
|
||||
{
|
||||
|
|
@ -126,8 +114,8 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[900px] pb-10">
|
||||
<section className="relative py-4">
|
||||
<div className="mx-auto w-full max-w-[680px] pb-10">
|
||||
<section className="py-4">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -139,80 +127,76 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
|||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
|
||||
{mode === "team"
|
||||
? "Pick where your team asks questions, then set it up."
|
||||
? "Add Supermemory to Slack so your team can ask in any channel."
|
||||
: "Pick the tool you open every day — about 60 seconds."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
|
||||
{/* Left rail: pick a tool */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<FlowToolRow
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
active={selected === tool.id}
|
||||
onSelect={() => {
|
||||
analytics.onboardingAgentSelected({ agent: tool.id })
|
||||
setSelected(tool.id)
|
||||
<div
|
||||
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{tools.length > 1 ? (
|
||||
<>
|
||||
<FlowTabs
|
||||
tools={tools}
|
||||
selected={selected}
|
||||
onSelect={(id) => {
|
||||
analytics.onboardingAgentSelected({ agent: id })
|
||||
setSelected(id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right pane: setup detail */}
|
||||
<div
|
||||
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-10 left-10 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[18px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-5 mb-4 px-1 text-[13px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="mb-5 flex items-center gap-3 px-1">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-6 flex items-center gap-3 border-t border-white/[0.06] pt-5",
|
||||
tools.length > 1 ? "justify-between" : "justify-end",
|
||||
)}
|
||||
>
|
||||
{tools.length > 1 && (
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
|
|
@ -253,77 +237,47 @@ function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FlowToolRow({
|
||||
tool,
|
||||
active,
|
||||
function FlowTabs({
|
||||
tools,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: FlowTool
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
tools: readonly FlowTool[]
|
||||
selected: FlowToolId
|
||||
onSelect: (id: FlowToolId) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
|
||||
active
|
||||
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
|
||||
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
<div
|
||||
className="grid gap-1 rounded-[14px] border border-[rgba(115,115,115,0.2)] bg-[#0D121A] p-1"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${tools.length}, minmax(0,1fr))`,
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-5 left-5 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
|
||||
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{tool.label}
|
||||
</p>
|
||||
{tool.recommended && (
|
||||
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{tool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
active
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
||||
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
|
||||
)}
|
||||
>
|
||||
{active && <Check className="size-3 text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
{tools.map((tool) => {
|
||||
const active = tool.id === selected
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tool.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"relative flex h-10 items-center justify-center gap-2 rounded-[10px] border border-transparent px-2 text-[13px] font-medium transition-colors",
|
||||
active ? "text-[#fafafa]" : "text-[#737373] hover:text-[#fafafa]",
|
||||
)}
|
||||
style={
|
||||
active
|
||||
? { background: "#00173C", borderColor: "#2261CA66" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ToolIcon id={tool.id} className="size-4 shrink-0" />
|
||||
<span className="truncate">{tool.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -332,26 +286,36 @@ function StepRow({
|
|||
title,
|
||||
done,
|
||||
children,
|
||||
last,
|
||||
}: {
|
||||
index: number
|
||||
title: ReactNode
|
||||
done?: boolean
|
||||
children?: ReactNode
|
||||
last?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
{!last && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1.5 w-px flex-1 bg-[rgba(82,89,102,0.3)]"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
</div>
|
||||
<div className={cn("min-w-0 flex-1 pt-0.5", last ? "pb-0" : "pb-6")}>
|
||||
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
|
||||
{title}
|
||||
</div>
|
||||
|
|
@ -370,7 +334,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
{steps.map((step, i) => (
|
||||
<StepRow key={step.title} index={i + 1} title={step.title}>
|
||||
{step.description ? (
|
||||
|
|
@ -381,7 +345,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
{step.code ? <CopyCodeBlock code={step.code} /> : null}
|
||||
</StepRow>
|
||||
))}
|
||||
<StepRow index={steps.length + 1} title="Ask your brain to test it">
|
||||
<StepRow index={steps.length + 1} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
@ -390,7 +354,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
|
|||
|
||||
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<StepRow index={1} title="Copy your universal MCP URL">
|
||||
<McpUrlRow url={mcpUrl} />
|
||||
</StepRow>
|
||||
|
|
@ -403,7 +367,7 @@ function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
|||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</StepRow>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<StepRow index={3} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
@ -444,35 +408,36 @@ function SlackSetupPanel() {
|
|||
const connected = status?.connected ?? false
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<StepRow
|
||||
index={1}
|
||||
done={connected}
|
||||
title={
|
||||
connected
|
||||
? `Connected to ${status?.teamName ?? "your workspace"}`
|
||||
: "Add Supermemory to your Slack workspace"
|
||||
}
|
||||
>
|
||||
{!connected &&
|
||||
(loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
connected ? (
|
||||
`Connected to ${status?.teamName ?? "your workspace"}`
|
||||
) : (
|
||||
<Button
|
||||
variant="insideOut"
|
||||
asChild
|
||||
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</StepRow>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="pt-1">
|
||||
Add Supermemory to your Slack workspace
|
||||
</span>
|
||||
{loading ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StepRow
|
||||
index={2}
|
||||
title={
|
||||
|
|
@ -482,7 +447,7 @@ function SlackSetupPanel() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<StepRow index={3} last title="Ask your brain to test it">
|
||||
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
|
||||
</StepRow>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useQueryState } from "nuqs"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog"
|
||||
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -98,9 +99,11 @@ import {
|
|||
type PlanType,
|
||||
useTokenUsage,
|
||||
} from "@/hooks/use-token-usage"
|
||||
import { GranolaConnectModal } from "@/components/granola-connect-modal"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import {
|
||||
ADD_MEMORY_SHORTCUT_URL,
|
||||
CHROME_EXTENSION_URL,
|
||||
|
|
@ -117,6 +120,7 @@ type SourceId =
|
|||
| "gmail"
|
||||
| "github"
|
||||
| "onedrive"
|
||||
| "granola"
|
||||
| "bookmarks"
|
||||
| "chatapps"
|
||||
| "chrome"
|
||||
|
|
@ -126,6 +130,20 @@ type SourceState = "idle" | "connecting" | "connected" | "waitlist"
|
|||
type DriveScope = "selective" | "full"
|
||||
type RequiredPlan = "pro" | "max"
|
||||
|
||||
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
|
||||
"google-drive": "drive",
|
||||
notion: "notion",
|
||||
onedrive: "onedrive",
|
||||
granola: "granola",
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
|
||||
drive: "Google Drive",
|
||||
notion: "Notion",
|
||||
onedrive: "OneDrive",
|
||||
granola: "Granola",
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<RequiredPlan, string> = {
|
||||
pro: "Pro",
|
||||
max: "Max",
|
||||
|
|
@ -224,7 +242,6 @@ export interface SourcesValues {
|
|||
|
||||
interface Props {
|
||||
containerTag: string
|
||||
workspaceName: string
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
onChange: (next: SourcesValues) => void
|
||||
|
|
@ -281,7 +298,6 @@ function ChatAppsIconCluster() {
|
|||
|
||||
export function StepSources({
|
||||
containerTag,
|
||||
workspaceName,
|
||||
mode,
|
||||
values,
|
||||
onChange,
|
||||
|
|
@ -289,20 +305,99 @@ export function StepSources({
|
|||
}: Props) {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [plansOpen, setPlansOpen] = useState(false)
|
||||
const [granolaOpen, setGranolaOpen] = useState(false)
|
||||
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
|
||||
const [requestedConnector, setRequestedConnector] = useState("This connector")
|
||||
const autumn = useCustomer()
|
||||
const hasPro = hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
const planLoading = autumn.isLoading
|
||||
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
|
||||
const { org, isRestoring } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
setMoreOpen(false)
|
||||
}, [])
|
||||
|
||||
const valuesRef = useRef(values)
|
||||
valuesRef.current = values
|
||||
// dedupe toasts across the param + reconcile paths
|
||||
const announced = useRef(new Set<SourceId>())
|
||||
const seeded = useRef(false)
|
||||
|
||||
const markConnected = (ids: SourceId[]) => {
|
||||
const current = valuesRef.current
|
||||
const updates: Partial<Record<SourceId, SourceState>> = {}
|
||||
for (const id of ids) {
|
||||
if (current.connected[id] !== "connected") updates[id] = "connected"
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
onChange({
|
||||
...current,
|
||||
connected: { ...current.connected, ...updates },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// keyed by org so it re-runs once the active org restores on reload
|
||||
const { data: liveConnections, refetch: refetchConnections } = useQuery({
|
||||
queryKey: ["onboarding-connections", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (res.error) return [] as Array<{ provider?: string }>
|
||||
return (res.data ?? []) as Array<{ provider?: string }>
|
||||
},
|
||||
enabled: !isRestoring && !!org?.id,
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reconcile on fetched connections only
|
||||
useEffect(() => {
|
||||
if (!liveConnections) return
|
||||
const ids = liveConnections
|
||||
.map((c) => (c.provider ? PROVIDER_TO_SOURCE[c.provider] : undefined))
|
||||
.filter((id): id is SourceId => Boolean(id))
|
||||
markConnected(ids)
|
||||
// first load: seed without toasting pre-existing connections
|
||||
if (!seeded.current) {
|
||||
seeded.current = true
|
||||
for (const id of ids) announced.current.add(id)
|
||||
return
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (!announced.current.has(id)) {
|
||||
announced.current.add(id)
|
||||
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
|
||||
}
|
||||
}
|
||||
}, [liveConnections])
|
||||
|
||||
// Post-OAuth redirect lands with ?connected=<provider> — confirm it instantly.
|
||||
const [connectedParam, setConnectedParam] = useQueryState("connected")
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: run when the param arrives
|
||||
useEffect(() => {
|
||||
if (!connectedParam) return
|
||||
const id = PROVIDER_TO_SOURCE[connectedParam]
|
||||
if (id) {
|
||||
markConnected([id])
|
||||
if (!announced.current.has(id)) {
|
||||
announced.current.add(id)
|
||||
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
|
||||
}
|
||||
}
|
||||
setConnectedParam(null)
|
||||
const t1 = setTimeout(() => void refetchConnections(), 1500)
|
||||
const t2 = setTimeout(() => void refetchConnections(), 4000)
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
}, [connectedParam])
|
||||
|
||||
// company_brain unlocks pro connectors; max stays gated
|
||||
const isLocked = (plan?: RequiredPlan) => {
|
||||
if (!plan || planLoading) return false
|
||||
return plan === "max" ? !hasMax : !hasPro
|
||||
if (plan === "max") return !hasMax
|
||||
return !connectorAccess
|
||||
}
|
||||
|
||||
const setState = (id: SourceId, state: SourceState) => {
|
||||
|
|
@ -382,71 +477,91 @@ export function StepSources({
|
|||
<div className="mx-auto w-full max-w-[1400px] pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-4">
|
||||
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-6 px-1">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{mode === "personal"
|
||||
? "Bring your context together"
|
||||
: "Connect your team's signals"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Start with the sources that carry the most context. Add more
|
||||
anytime.
|
||||
</p>
|
||||
</div>
|
||||
<RoutingChip workspaceName={workspaceName} />
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{mode === "personal"
|
||||
? "Bring your context together"
|
||||
: "Connect your team's signals"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Start with the sources that carry the most context. Add more
|
||||
anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<SourceCard
|
||||
title="Import bookmarks"
|
||||
blurb="One-shot import of your saved tweets."
|
||||
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.bookmarks ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Bookmarks become searchable memories",
|
||||
"One-click import from the X bookmarks tab",
|
||||
"Works via the Chrome extension",
|
||||
]}
|
||||
onConnect={() => openExternal("bookmarks", CHROME_EXTENSION_URL)}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Import from AI chat apps"
|
||||
blurb="Bring memories from ChatGPT, Claude, Grok & more."
|
||||
icon={<ChatAppsIconCluster />}
|
||||
bareIconFrame
|
||||
state={values.connected.chatapps ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Sync your ChatGPT memories",
|
||||
"Carry context across every assistant",
|
||||
"Import once, recall anywhere",
|
||||
]}
|
||||
onConnect={() => openExternal("chatapps", CHROME_EXTENSION_URL)}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<NotionSourceCard
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<>
|
||||
<SourceCard
|
||||
title="Import bookmarks"
|
||||
blurb="One-shot import of your saved tweets."
|
||||
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.bookmarks ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Bookmarks become searchable memories",
|
||||
"One-click import from the X bookmarks tab",
|
||||
"Works via the Chrome extension",
|
||||
]}
|
||||
onConnect={() =>
|
||||
openExternal("bookmarks", CHROME_EXTENSION_URL)
|
||||
}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Import from AI chat apps"
|
||||
blurb="Bring memories from ChatGPT, Claude, Grok & more."
|
||||
icon={<ChatAppsIconCluster />}
|
||||
bareIconFrame
|
||||
state={values.connected.chatapps ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
doneLabel="Opened"
|
||||
perks={[
|
||||
"Sync your ChatGPT memories",
|
||||
"Carry context across every assistant",
|
||||
"Import once, recall anywhere",
|
||||
]}
|
||||
onConnect={() =>
|
||||
openExternal("chatapps", CHROME_EXTENSION_URL)
|
||||
}
|
||||
/>
|
||||
<NotionSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<GoogleDriveSourceCard
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<>
|
||||
<NotionSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
<GranolaSourceCard
|
||||
state={values.connected.granola ?? "idle"}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
onOpen={() => setGranolaOpen(true)}
|
||||
/>
|
||||
<GoogleDriveSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -465,7 +580,7 @@ export function StepSources({
|
|||
/>
|
||||
More integrations
|
||||
<span className="text-[#525D6E]">
|
||||
(Notion, Gmail, GitHub, OneDrive…)
|
||||
(Gmail, GitHub, OneDrive…)
|
||||
</span>
|
||||
</button>
|
||||
<SourceActions
|
||||
|
|
@ -486,6 +601,7 @@ export function StepSources({
|
|||
openExternal={openExternal}
|
||||
requestWaitlist={requestWaitlist}
|
||||
connectRealProvider={connectRealProvider}
|
||||
onOpenGranola={() => setGranolaOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -500,6 +616,15 @@ export function StepSources({
|
|||
requestedConnector={requestedConnector}
|
||||
requestedPlan={requestedPlan}
|
||||
/>
|
||||
<GranolaConnectModal
|
||||
open={granolaOpen}
|
||||
onOpenChange={setGranolaOpen}
|
||||
containerTags={[containerTag]}
|
||||
onSuccess={() => {
|
||||
announced.current.add("granola")
|
||||
markConnected(["granola"])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -832,12 +957,14 @@ function SourceActions({
|
|||
}
|
||||
|
||||
function GoogleDriveSourceCard({
|
||||
mode,
|
||||
values,
|
||||
onChange,
|
||||
isLocked,
|
||||
guard,
|
||||
connectRealProvider,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
onChange: (next: SourcesValues) => void
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
|
|
@ -882,17 +1009,21 @@ function GoogleDriveSourceCard({
|
|||
onChange={(s) => onChange({ ...values, driveScope: s })}
|
||||
/>
|
||||
}
|
||||
footerRight={<SpaceChip name="My Drive" />}
|
||||
footerRight={
|
||||
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NotionSourceCard({
|
||||
mode,
|
||||
values,
|
||||
isLocked,
|
||||
guard,
|
||||
connectRealProvider,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
guard: (
|
||||
|
|
@ -922,7 +1053,46 @@ function NotionSourceCard({
|
|||
onConnect={guard("pro", "Notion", () =>
|
||||
connectRealProvider("notion", "notion"),
|
||||
)}
|
||||
footerRight={<SpaceChip name="Company Notion" />}
|
||||
footerRight={
|
||||
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function GranolaSourceCard({
|
||||
state,
|
||||
isLocked,
|
||||
guard,
|
||||
onOpen,
|
||||
}: {
|
||||
state: SourceState
|
||||
isLocked: (plan?: RequiredPlan) => boolean
|
||||
guard: (
|
||||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
) => () => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
return (
|
||||
<SourceCard
|
||||
title="Granola"
|
||||
blurb="Meeting notes into searchable decisions."
|
||||
icon={<Granola className="size-6" />}
|
||||
state={state}
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("pro")}
|
||||
requiredPlan="pro"
|
||||
perks={[
|
||||
"Meeting notes auto-captured",
|
||||
"Decisions and action items extracted",
|
||||
"Synced after every meeting",
|
||||
]}
|
||||
onConnect={guard("pro", "Granola", () => {
|
||||
analytics.onboardingIntegrationClicked({ integration: "granola" })
|
||||
onOpen()
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -936,6 +1106,7 @@ function MoreSourcesGrid({
|
|||
openExternal,
|
||||
requestWaitlist,
|
||||
connectRealProvider,
|
||||
onOpenGranola,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
values: SourcesValues
|
||||
|
|
@ -952,6 +1123,7 @@ function MoreSourcesGrid({
|
|||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
) => void
|
||||
onOpenGranola: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -999,20 +1171,14 @@ function MoreSourcesGrid({
|
|||
/>
|
||||
{mode === "personal" ? (
|
||||
<GoogleDriveSourceCard
|
||||
mode={mode}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
) : (
|
||||
<NotionSourceCard
|
||||
values={values}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<SourceCard
|
||||
title="OneDrive"
|
||||
blurb="Office docs from OneDrive."
|
||||
|
|
@ -1060,46 +1226,18 @@ function MoreSourcesGrid({
|
|||
]}
|
||||
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Granola"
|
||||
blurb="Meeting notes into searchable decisions."
|
||||
icon={<Granola className="size-6" />}
|
||||
state="idle"
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("max")}
|
||||
requiredPlan="max"
|
||||
perks={[
|
||||
"Meeting notes auto-captured",
|
||||
"Decisions and action items extracted",
|
||||
"Synced after every meeting",
|
||||
]}
|
||||
onConnect={guard("max", "Granola", () => {
|
||||
analytics.onboardingIntegrationClicked({ integration: "granola" })
|
||||
toast.info("Granola is coming soon.")
|
||||
})}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<GranolaSourceCard
|
||||
state={values.connected.granola ?? "idle"}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
onOpen={onOpenGranola}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RoutingChip({ workspaceName }: { workspaceName: string }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[12px]"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
title="All sources route to your brain. You can carve out spaces after setup."
|
||||
>
|
||||
<Logo className="size-3.5 text-[#8B8B8B]" />
|
||||
<span className="text-[#737373] font-medium">Routing to</span>
|
||||
<span className="text-[#fafafa] font-semibold">
|
||||
{workspaceName || "your brain"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
title,
|
||||
blurb,
|
||||
|
|
@ -1167,8 +1305,8 @@ function SourceCard({
|
|||
{headerNote}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-[#4BA0FA] font-semibold uppercase tracking-[0.08em] shrink-0 mt-1">
|
||||
<Check className="size-3" />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
||||
<Check className="size-3.5" />
|
||||
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function StepTeam({
|
|||
const count = values.invites.length
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
|
||||
style={modalCardStyle}
|
||||
|
|
@ -294,38 +294,38 @@ export function StepTeam({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
Sending…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{count > 0
|
||||
? `Send ${count} invite${count === 1 ? "" : "s"}`
|
||||
: "Continue"}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
Sending…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{count > 0
|
||||
? `Send ${count} invite${count === 1 ? "" : "s"}`
|
||||
: "Continue"}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
export type CompanyBrainConfirmResult =
|
||||
| { ok: true; serverSchedulesResearch: boolean }
|
||||
| { ok: false }
|
||||
|
||||
export type BrainMode = "personal" | "team"
|
||||
|
||||
export type BrainStep = "about" | "sources" | "ingest" | "team"
|
||||
|
|
@ -66,6 +70,22 @@ export function workspaceNameFromEmail(
|
|||
return root.charAt(0).toUpperCase() + root.slice(1)
|
||||
}
|
||||
|
||||
/** e.g. duolingo.com → Duolingo (first hostname label, title-cased). */
|
||||
export function workspaceNameFromDomain(
|
||||
domain: string | undefined | null,
|
||||
): string {
|
||||
if (!domain) return ""
|
||||
const clean = domain
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
const host = clean.split(".")[0] ?? ""
|
||||
if (!host) return ""
|
||||
return host.charAt(0).toUpperCase() + host.slice(1)
|
||||
}
|
||||
|
||||
export function workspaceDomainFromEmail(
|
||||
email: string | undefined | null,
|
||||
): string | null {
|
||||
|
|
|
|||
79
apps/web/components/review-memories-card.tsx
Normal file
79
apps/web/components/review-memories-card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client"
|
||||
|
||||
import { ReviewMemoriesModal } from "@/components/review-memories-modal"
|
||||
import { useInferredMemories } from "@/hooks/use-inferred-memories"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
// "Suggested for you" entry point — opens the swipe-review modal. The trigger
|
||||
// hides when there's nothing to review, but the modal stays mounted while open
|
||||
// (even after the last card) so it can show its "all caught up" summary.
|
||||
// Styled to match the Weekly digest box above it.
|
||||
export function ReviewMemoriesCard({
|
||||
containerTag,
|
||||
className,
|
||||
}: {
|
||||
containerTag: string | undefined
|
||||
className?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: memories = [] } = useInferredMemories(containerTag)
|
||||
const liveCount = memories.length
|
||||
|
||||
// While the modal is open, freeze the count shown on the trigger so the
|
||||
// background button stays put (doesn't tick down or vanish) until it closes.
|
||||
const [frozenCount, setFrozenCount] = useState(0)
|
||||
const count = open ? frozenCount : liveCount
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (next) setFrozenCount(liveCount)
|
||||
setOpen(next)
|
||||
}
|
||||
|
||||
// Switching spaces shouldn't carry an open modal over to the new space.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset on space switch
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [containerTag])
|
||||
|
||||
return (
|
||||
<>
|
||||
{count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between gap-3 rounded-xl bg-[#0c1a30]/80 px-3.5 py-3 text-left shadow-[0_12px_40px_rgba(0,0,0,0.22)] ring-1 ring-[#4BA0FA]/20 backdrop-blur-md transition-colors hover:bg-[#0e2038]/90 hover:ring-[#4BA0FA]/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold leading-tight text-fg-primary">
|
||||
Review suggestions
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] leading-tight text-fg-faint">
|
||||
{count} {count === 1 ? "memory" : "memories"} we inferred for you
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-2 text-fg-faint transition-colors group-hover:text-fg-muted">
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-accent px-1.5 text-[10px] font-bold text-[#00111f]">
|
||||
{count}
|
||||
</span>
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(open || liveCount > 0) && (
|
||||
<ReviewMemoriesModal
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
containerTag={containerTag}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
477
apps/web/components/review-memories-modal.tsx
Normal file
477
apps/web/components/review-memories-modal.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
"use client"
|
||||
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type InferredMemory,
|
||||
useInferredMemories,
|
||||
useInferredMemoryCache,
|
||||
useReviewInferredMemory,
|
||||
} from "@/hooks/use-inferred-memories"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent } from "@ui/components/dialog"
|
||||
import { Check, Undo2, X } from "lucide-react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
} from "motion/react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
// Distance / velocity past which a drag commits to a decision.
|
||||
const SWIPE_OFFSET = 120
|
||||
const SWIPE_VELOCITY = 600
|
||||
const STACK_DEPTH = 3 // how many cards are visible at once
|
||||
|
||||
type Decision = "approve" | "decline" | "skip"
|
||||
|
||||
export function ReviewMemoriesModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
containerTag,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
containerTag: string | undefined
|
||||
}) {
|
||||
const { data: memories = [] } = useInferredMemories(containerTag)
|
||||
const { mutate: review } = useReviewInferredMemory(containerTag)
|
||||
const queue = useInferredMemoryCache(containerTag)
|
||||
const reduceMotion = useReducedMotion()
|
||||
|
||||
// Snapshot the queue when the modal opens so cache updates from the
|
||||
// mutation don't reshuffle the stack mid-session.
|
||||
const [cards, setCards] = useState<InferredMemory[]>([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [exitDir, setExitDir] = useState(1)
|
||||
const [approved, setApproved] = useState(0)
|
||||
const [history, setHistory] = useState<{ id: string; decision: Decision }[]>(
|
||||
[],
|
||||
)
|
||||
// Synchronous source of truth so rapid swipes/undos don't read stale state.
|
||||
const indexRef = useRef(0)
|
||||
const historyRef = useRef<{ id: string; decision: Decision }[]>([])
|
||||
|
||||
// Intentionally keyed on `open` only: including `memories` would reset the
|
||||
// stack every time the review mutation trims the cache. The queue is loaded
|
||||
// by the time the trigger renders, so a snapshot on open is sufficient.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot-on-open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCards(memories)
|
||||
setIndex(0)
|
||||
indexRef.current = 0
|
||||
setApproved(0)
|
||||
setHistory([])
|
||||
historyRef.current = []
|
||||
}, [open])
|
||||
|
||||
const current = cards[index]
|
||||
const done = cards.length > 0 && index >= cards.length
|
||||
|
||||
const decide = useCallback(
|
||||
(decision: Decision) => {
|
||||
const card = cards[indexRef.current]
|
||||
if (!card) return
|
||||
// 1 = fly right (approve), -1 = fly left (decline), 0 = drop down (skip)
|
||||
setExitDir(decision === "approve" ? 1 : decision === "decline" ? -1 : 0)
|
||||
if (decision === "approve") {
|
||||
setApproved((n) => n + 1)
|
||||
review({ memoryId: card.id, action: "approve" })
|
||||
} else if (decision === "decline") {
|
||||
review({ memoryId: card.id, action: "decline" })
|
||||
} else {
|
||||
// Skip persists nothing server-side, but drop it from the cached queue
|
||||
// so the trigger's live count falls and the prompt can be dismissed.
|
||||
queue.drop(card.id)
|
||||
}
|
||||
historyRef.current = [...historyRef.current, { id: card.id, decision }]
|
||||
setHistory(historyRef.current)
|
||||
indexRef.current += 1
|
||||
setIndex(indexRef.current)
|
||||
},
|
||||
[cards, review, queue.drop],
|
||||
)
|
||||
|
||||
const canUndo = history.length > 0
|
||||
|
||||
// Step back one card and revert its server-side review (skip had none).
|
||||
// Reads from refs so consecutive/rapid undos each target the correct card.
|
||||
const undo = useCallback(() => {
|
||||
const last = historyRef.current[historyRef.current.length - 1]
|
||||
if (!last) return
|
||||
historyRef.current = historyRef.current.slice(0, -1)
|
||||
setHistory(historyRef.current)
|
||||
if (last.decision === "approve") {
|
||||
setApproved((n) => Math.max(0, n - 1))
|
||||
review({ memoryId: last.id, action: "undo" })
|
||||
} else if (last.decision === "decline") {
|
||||
review({ memoryId: last.id, action: "undo" })
|
||||
} else {
|
||||
const card = cards.find((c) => c.id === last.id)
|
||||
if (card) queue.restore(card)
|
||||
}
|
||||
indexRef.current = Math.max(0, indexRef.current - 1)
|
||||
setIndex(indexRef.current)
|
||||
}, [review, cards, queue.restore])
|
||||
|
||||
// Keyboard: ← decline, → approve, ↓/space skip.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
return
|
||||
}
|
||||
if (done || !current) return
|
||||
if (e.key === "ArrowRight") decide("approve")
|
||||
else if (e.key === "ArrowLeft") decide("decline")
|
||||
else if (e.key === "ArrowDown" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
decide("skip")
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, done, current, decide, undo])
|
||||
|
||||
const visible = cards.slice(index, index + STACK_DEPTH)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"border-surface-border bg-surface-base p-0 gap-0 overflow-hidden sm:max-w-[420px] rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
showCloseButton={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4">
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold leading-tight text-fg-primary">
|
||||
Suggested memories
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-tight text-fg-faint">
|
||||
Memories we inferred — keep the ones that fit
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="-mr-1 -mt-1 rounded-full p-1.5 text-fg-faint transition-colors hover:bg-white/[0.06] hover:text-fg-muted"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress dots + undo + counter */}
|
||||
{cards.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 pb-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{cards.map((c, i) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all duration-300",
|
||||
i === index
|
||||
? "w-5 bg-brand-accent"
|
||||
: i < index
|
||||
? "w-1.5 bg-brand-accent/40"
|
||||
: "w-1.5 bg-white/15",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2.5 pl-3">
|
||||
{canUndo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<Undo2 className="size-3.5" />
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] tabular-nums text-fg-faint">
|
||||
{Math.min(index + 1, cards.length)}/{cards.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card deck */}
|
||||
<div className="relative px-6 pt-4 pb-3">
|
||||
<div className="relative h-[340px]">
|
||||
{/* Nova glow halo behind the deck */}
|
||||
{!done && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -inset-3 rounded-[28px] opacity-70 blur-2xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(58% 52% at 50% 42%, rgba(75,160,250,0.45), rgba(54,216,196,0.16) 58%, transparent 76%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{done ? (
|
||||
<DoneState approved={approved} total={cards.length} />
|
||||
) : (
|
||||
<AnimatePresence custom={exitDir} initial={false}>
|
||||
{visible.map((mem, i) => (
|
||||
<SwipeCard
|
||||
key={mem.id}
|
||||
memory={mem}
|
||||
depth={i}
|
||||
globalIndex={index + i}
|
||||
isTop={i === 0}
|
||||
reduceMotion={!!reduceMotion}
|
||||
onDecide={decide}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{!done && current && (
|
||||
<div className="flex items-center justify-center gap-3 px-5 pb-6 pt-3">
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Decline"
|
||||
onClick={() => decide("decline")}
|
||||
whileHover={{ scale: 1.07 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex size-14 items-center justify-center rounded-full border border-[#ff6b6b]/30 bg-[#ff6b6b]/10 text-[#ff6b6b] transition-colors hover:bg-[#ff6b6b]/20"
|
||||
>
|
||||
<X className="size-6" />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => decide("skip")}
|
||||
whileTap={{ scale: 0.94 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="px-4 text-[13px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
|
||||
>
|
||||
Skip
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Keep"
|
||||
onClick={() => decide("approve")}
|
||||
whileHover={{ scale: 1.07 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex size-14 items-center justify-center rounded-full border border-[#4ade80]/30 bg-[#4ade80]/10 text-[#4ade80] transition-colors hover:bg-[#4ade80]/20"
|
||||
>
|
||||
<Check className="size-6" />
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SwipeCard({
|
||||
memory,
|
||||
depth,
|
||||
globalIndex,
|
||||
isTop,
|
||||
reduceMotion,
|
||||
onDecide,
|
||||
}: {
|
||||
memory: InferredMemory
|
||||
depth: number
|
||||
globalIndex: number
|
||||
isTop: boolean
|
||||
reduceMotion: boolean
|
||||
onDecide: (d: Decision) => void
|
||||
}) {
|
||||
const x = useMotionValue(0)
|
||||
const rotate = useTransform(x, [-200, 200], reduceMotion ? [0, 0] : [-8, 8])
|
||||
|
||||
// Slack-style: the whole card washes green (keep) / red (decline) and the
|
||||
// border + verdict pill intensify the closer you get to committing.
|
||||
const wash = useTransform(
|
||||
x,
|
||||
[-150, -30, 0, 30, 150],
|
||||
[
|
||||
"rgba(239,68,68,0.34)",
|
||||
"rgba(239,68,68,0.04)",
|
||||
"rgba(255,255,255,0)",
|
||||
"rgba(74,222,128,0.04)",
|
||||
"rgba(74,222,128,0.34)",
|
||||
],
|
||||
)
|
||||
const borderColor = useTransform(
|
||||
x,
|
||||
[-150, -30, 0, 30, 150],
|
||||
[
|
||||
"rgba(239,68,68,0.7)",
|
||||
"rgba(255,255,255,0.08)",
|
||||
"rgba(255,255,255,0.08)",
|
||||
"rgba(255,255,255,0.08)",
|
||||
"rgba(74,222,128,0.7)",
|
||||
],
|
||||
)
|
||||
const keepOpacity = useTransform(x, [30, 120], [0, 1])
|
||||
const keepScale = useTransform(x, [30, 120], [0.7, 1])
|
||||
const declineOpacity = useTransform(x, [-120, -30], [1, 0])
|
||||
const declineScale = useTransform(x, [-120, -30], [1, 0.7])
|
||||
|
||||
const scale = 1 - depth * 0.055
|
||||
const y = depth * 16
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
x: isTop ? x : 0,
|
||||
rotate: isTop ? rotate : 0,
|
||||
// Absolute position keeps the exiting card above the one rising behind.
|
||||
zIndex: 999 - globalIndex,
|
||||
}}
|
||||
variants={{
|
||||
exit: (dir: number) => {
|
||||
if (reduceMotion)
|
||||
return { opacity: 0, transition: { duration: 0.25 } }
|
||||
// dir 0 = skip → drop straight down; ±1 = fly out sideways.
|
||||
if (dir === 0)
|
||||
return {
|
||||
y: 480,
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] },
|
||||
}
|
||||
return {
|
||||
x: dir * 540,
|
||||
opacity: 0,
|
||||
rotate: dir * 12,
|
||||
transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] },
|
||||
}
|
||||
},
|
||||
}}
|
||||
exit="exit"
|
||||
initial={{ scale: scale - 0.04, y: y + 10, opacity: 0 }}
|
||||
animate={{
|
||||
scale,
|
||||
y,
|
||||
opacity: depth === 0 ? 1 : depth === 1 ? 0.8 : 0.45,
|
||||
}}
|
||||
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
|
||||
drag={isTop ? "x" : false}
|
||||
dragSnapToOrigin
|
||||
dragElastic={0.7}
|
||||
whileDrag={{ scale: 1.02 }}
|
||||
onDragEnd={(_e, info) => {
|
||||
if (!isTop) return
|
||||
const { offset, velocity } = info
|
||||
if (offset.x > SWIPE_OFFSET || velocity.x > SWIPE_VELOCITY)
|
||||
onDecide("approve")
|
||||
else if (offset.x < -SWIPE_OFFSET || velocity.x < -SWIPE_VELOCITY)
|
||||
onDecide("decline")
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
style={isTop ? { borderColor } : undefined}
|
||||
className={cn(
|
||||
"relative h-full overflow-hidden rounded-[20px] border border-white/[0.08] bg-surface-card shadow-[0_20px_50px_-12px_rgba(0,0,0,0.55)]",
|
||||
isTop && "cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
{/* Full-card color wash that tracks the swipe */}
|
||||
{isTop && (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ backgroundColor: wash }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Verdict pills — slide/scale in toward the swipe direction */}
|
||||
{isTop && (
|
||||
<>
|
||||
<motion.div
|
||||
style={{ opacity: keepOpacity, scale: keepScale }}
|
||||
className="pointer-events-none absolute right-4 top-4 flex items-center gap-1.5 rounded-full bg-[#4ade80] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#06230f] shadow-lg"
|
||||
>
|
||||
<Check className="size-3.5" /> Keep
|
||||
</motion.div>
|
||||
<motion.div
|
||||
style={{ opacity: declineOpacity, scale: declineScale }}
|
||||
className="pointer-events-none absolute left-4 top-4 flex items-center gap-1.5 rounded-full bg-[#ff6b6b] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#2a0808] shadow-lg"
|
||||
>
|
||||
<X className="size-3.5" /> Decline
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative flex h-full flex-col p-6">
|
||||
<div className="flex shrink-0 items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-brand-accent/85">
|
||||
<span className="size-1.5 rounded-full bg-brand-accent shadow-[0_0_8px_rgba(75,160,250,0.8)]" />
|
||||
Inferred
|
||||
</div>
|
||||
|
||||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
|
||||
<p className="text-[19px] font-medium leading-[1.32] tracking-[-0.01em] text-fg-primary">
|
||||
{memory.memory}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex shrink-0 items-center gap-2 text-[11px] text-fg-faint">
|
||||
{memory.parentCount > 0 && (
|
||||
<span className="rounded-full bg-white/[0.04] px-2 py-0.5 ring-1 ring-white/[0.06]">
|
||||
{memory.parentCount === 1
|
||||
? "from 1 memory"
|
||||
: `from ${memory.parentCount} memories`}
|
||||
</span>
|
||||
)}
|
||||
<span>{relativeTime(memory.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoneState({ approved, total }: { approved: number; total: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="flex h-full flex-col items-center justify-center gap-3 text-center"
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-full bg-[#4ade801a] ring-1 ring-[#4ade8055]">
|
||||
<Check className="size-7 text-[#4ade80]" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-fg-primary">All caught up</p>
|
||||
<p className="mt-1 text-[13px] text-fg-faint">
|
||||
{approved > 0
|
||||
? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.`
|
||||
: "Nothing kept this time — they'll stay tucked away."}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return ""
|
||||
const diff = Date.now() - then
|
||||
const day = 86_400_000
|
||||
if (diff < day) return "today"
|
||||
if (diff < 2 * day) return "yesterday"
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`
|
||||
if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago`
|
||||
return `${Math.floor(diff / (30 * day))}mo ago`
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
Tag,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
|
@ -174,6 +175,10 @@ export default function Account() {
|
|||
setIsEditingOrgName(false)
|
||||
}, [org?.name])
|
||||
|
||||
// Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog.
|
||||
// Consumed below, once the role is known and only for admins/owners.
|
||||
const [inviteParam, setInviteParam] = useQueryState("invite")
|
||||
|
||||
const activeMemberRoleQuery = useQuery({
|
||||
queryKey: ["organization", org?.id, "active-member-role"],
|
||||
queryFn: async () => {
|
||||
|
|
@ -209,6 +214,18 @@ export default function Account() {
|
|||
const canManageTeam = currentRole === "owner" || currentRole === "admin"
|
||||
const isOwner = currentRole === "owner"
|
||||
|
||||
// Consume ?invite=1 only after the role resolves, and only for managers.
|
||||
useEffect(() => {
|
||||
if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return
|
||||
if (canManageTeam) setInviteDialogOpen(true)
|
||||
setInviteParam(null)
|
||||
}, [
|
||||
inviteParam,
|
||||
activeMemberRoleQuery.isLoading,
|
||||
canManageTeam,
|
||||
setInviteParam,
|
||||
])
|
||||
|
||||
const pendingInvitations = useMemo(
|
||||
() => (org?.invitations ?? []).filter(isPendingInvitation),
|
||||
[org?.invitations],
|
||||
|
|
|
|||
1084
apps/web/components/settings/company-brain-automations.tsx
Normal file
1084
apps/web/components/settings/company-brain-automations.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,13 +8,45 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type ConnRow = { toolkit: string; org: boolean; user: boolean }
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
type AuthType = "oauth" | "static" | "none"
|
||||
type CatalogEntry = {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
authType: AuthType
|
||||
tokenHint?: string
|
||||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
serverUrl?: string
|
||||
authType: AuthType
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
}
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
type Scope = "org" | "user"
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function slugifyMcpName(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
|
|
@ -37,85 +69,12 @@ function SecondaryButton({
|
|||
)
|
||||
}
|
||||
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Linear</title>
|
||||
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const TOOLKITS: Record<
|
||||
string,
|
||||
{ label: string; subtitle: string; icon: React.ReactNode }
|
||||
> = {
|
||||
github: {
|
||||
label: "GitHub",
|
||||
subtitle: "Repos, pull requests and issues",
|
||||
icon: <GithubMark className="size-5 text-[#FAFAFA]" />,
|
||||
},
|
||||
linear: {
|
||||
label: "Linear",
|
||||
subtitle: "Issues, projects and cycles",
|
||||
icon: <LinearMark className="size-5 text-[#5E6AD2]" />,
|
||||
},
|
||||
}
|
||||
|
||||
function StatusDot({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-1.5 text-[13px] font-medium",
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -131,7 +90,9 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
}
|
||||
|
||||
function AppCard({
|
||||
toolkit,
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
connected,
|
||||
canConnect,
|
||||
canDisconnect,
|
||||
|
|
@ -140,7 +101,9 @@ function AppCard({
|
|||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
toolkit: string
|
||||
name: string
|
||||
subtitle: string
|
||||
icon: React.ReactNode
|
||||
connected: boolean
|
||||
canConnect: boolean
|
||||
canDisconnect: boolean
|
||||
|
|
@ -149,37 +112,32 @@ function AppCard({
|
|||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
}) {
|
||||
const meta = TOOLKITS[toolkit] ?? {
|
||||
label: toolkit,
|
||||
subtitle: "",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{meta.icon}
|
||||
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[12px] font-medium text-[#737373]",
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{meta.subtitle}
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<StatusDot connected={connected} />
|
||||
{connected && canDisconnect ? (
|
||||
<PillButton onClick={onDisconnect} disabled={busy}>
|
||||
|
|
@ -205,57 +163,65 @@ function AppCard({
|
|||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
scope: Scope
|
||||
onChange: (s: Scope) => void
|
||||
}) {
|
||||
const items: { id: Scope; label: string }[] = [
|
||||
{ id: "org", label: "Organization" },
|
||||
{ id: "user", label: "Personal" },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="px-1">
|
||||
<p
|
||||
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => onChange(it.id)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
|
||||
scope === it.id
|
||||
? "bg-[#1E293B] text-[#FAFAFA]"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
function RowSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[14px] bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-3 w-40 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainConnections() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [rows, setRows] = useState<ConnRow[] | null>(null)
|
||||
const { user } = useAuth()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>("user")
|
||||
const [customName, setCustomName] = useState("")
|
||||
const [customServerUrl, setCustomServerUrl] = useState("")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
|
|
@ -268,15 +234,25 @@ export default function CompanyBrainConnections() {
|
|||
const isAdmin = role === "owner" || role === "admin"
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [connRes, slackRes] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
const [catRes, connRes, slackRes] = await Promise.all([
|
||||
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
||||
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (catRes.ok) {
|
||||
const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
|
||||
setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
|
||||
setCatalogLoaded(true)
|
||||
} else {
|
||||
setCatalog([])
|
||||
setCatalogLoaded(false)
|
||||
toast.error("Couldn't load the app catalog.")
|
||||
}
|
||||
if (connRes.ok) {
|
||||
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
const data = (await connRes.json()) as { connections?: ConnRow[] }
|
||||
setRows(Array.isArray(data.connections) ? data.connections : [])
|
||||
} else {
|
||||
setRows([])
|
||||
toast.error("Couldn't load connections.")
|
||||
}
|
||||
if (slackRes.ok) {
|
||||
setSlackStatus((await slackRes.json()) as SlackStatus)
|
||||
|
|
@ -293,13 +269,52 @@ export default function CompanyBrainConnections() {
|
|||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [isCompanyBrain, load])
|
||||
|
||||
const connect = async (toolkit: string, scope: "user" | "org") => {
|
||||
setBusy(`${toolkit}:${scope}`)
|
||||
// A row with userId === null is the org-shared connection; any other row
|
||||
// returned to the caller is their own personal one.
|
||||
const isConnected = (slug: string, shared: boolean) =>
|
||||
rows.some(
|
||||
(r) =>
|
||||
r.serverSlug === slug &&
|
||||
r.status === "active" &&
|
||||
(shared ? r.userId === null : r.userId !== null),
|
||||
)
|
||||
|
||||
const isStaff =
|
||||
user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
|
||||
|
||||
const connect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (entry.authType === "static") {
|
||||
const token = window.prompt(
|
||||
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
||||
)
|
||||
if (!token) return
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, shared }),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Only admins can connect the shared org account.")
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't connect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ shared, redirectUrl: window.location.href }),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Only admins can connect the shared org account.")
|
||||
return
|
||||
|
|
@ -308,9 +323,19 @@ export default function CompanyBrainConnections() {
|
|||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { url?: string; error?: string }
|
||||
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||
else toast.error(data.error ?? "Couldn't start the connection.")
|
||||
const data = (await res.json()) as {
|
||||
authUrl?: string
|
||||
ok?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
} else {
|
||||
toast.error(data.error ?? "Couldn't start the connection.")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
|
|
@ -318,18 +343,78 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
}
|
||||
|
||||
const disconnect = async (toolkit: string, scope: "user" | "org") => {
|
||||
const label = TOOLKITS[toolkit]?.label ?? toolkit
|
||||
const connectCustom = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const slug = slugifyMcpName(customName)
|
||||
const serverUrl = customServerUrl.trim()
|
||||
if (!slug) {
|
||||
toast.error("Enter a custom MCP name.")
|
||||
return
|
||||
}
|
||||
if (!serverUrl) {
|
||||
toast.error("Enter an OAuth MCP URL.")
|
||||
return
|
||||
}
|
||||
if (apps.some((entry) => entry.slug === slug)) {
|
||||
toast.error("That name is already used by a catalog app.")
|
||||
return
|
||||
}
|
||||
|
||||
const key = `custom:${slug}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(`${MCP_BASE}/${slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
serverUrl,
|
||||
shared: false,
|
||||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Custom MCP URLs are staff-only.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
authUrl?: string
|
||||
ok?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error(data.error ?? "Couldn't start the custom connection.")
|
||||
return
|
||||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${slug} connected.`)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
await load()
|
||||
} else {
|
||||
toast.error(data.error ?? "Couldn't start the custom connection.")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Couldn't start the custom connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Disconnect ${label} from ${scope === "org" ? "the shared org account" : "your personal account"}?`,
|
||||
`Disconnect ${entry.name} from ${shared ? "the shared org account" : "your personal account"}?`,
|
||||
)
|
||||
)
|
||||
return
|
||||
setBusy(`${toolkit}:${scope}`)
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}?scope=${scope}`,
|
||||
`${MCP_BASE}/${entry.slug}?shared=${shared ? "true" : "false"}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (res.status === 403) {
|
||||
|
|
@ -340,7 +425,7 @@ export default function CompanyBrainConnections() {
|
|||
toast.error("Couldn't disconnect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${label} disconnected.`)
|
||||
toast.success(`${entry.name} disconnected.`)
|
||||
await load()
|
||||
} catch {
|
||||
toast.error("Couldn't disconnect.")
|
||||
|
|
@ -362,16 +447,34 @@ export default function CompanyBrainConnections() {
|
|||
)
|
||||
}
|
||||
|
||||
const loading = rows === null
|
||||
const loading = catalog === null
|
||||
const apps = catalog ?? []
|
||||
const catalogSlugs = new Set(apps.map((entry) => entry.slug))
|
||||
const canClassifyCustomRows = catalogLoaded && apps.length > 0
|
||||
const customRows = canClassifyCustomRows
|
||||
? rows.filter(
|
||||
(row) =>
|
||||
row.userId !== null &&
|
||||
row.status === "active" &&
|
||||
typeof row.serverUrl === "string" &&
|
||||
row.serverUrl.length > 0 &&
|
||||
!catalogSlugs.has(row.serverSlug),
|
||||
)
|
||||
: []
|
||||
const shared = scope === "org"
|
||||
const description = shared
|
||||
? "Connected by admins. Used for reads when you haven't connected your own."
|
||||
: "Your personal accounts, used for your actions and your reads."
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
{slackStatus?.connected && slackStatus.teamName ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mr-auto text-[13px] font-medium text-[#737373]",
|
||||
"ml-auto text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Slack · {slackStatus.teamName}
|
||||
|
|
@ -384,50 +487,119 @@ export default function CompanyBrainConnections() {
|
|||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
<Section
|
||||
title="Organization (shared)"
|
||||
description="Connected by admins. Used for reads when you haven't connected your own."
|
||||
>
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<AppCard
|
||||
key={`org-${row.toolkit}`}
|
||||
toolkit={row.toolkit}
|
||||
connected={row.org}
|
||||
canConnect={isAdmin}
|
||||
canDisconnect={isAdmin}
|
||||
lockedHint="Admin only"
|
||||
busy={busy === `${row.toolkit}:org`}
|
||||
onConnect={() => connect(row.toolkit, "org")}
|
||||
onDisconnect={() => disconnect(row.toolkit, "org")}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Your connections"
|
||||
description="Your personal accounts — used for your actions and your reads."
|
||||
>
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<AppCard
|
||||
key={`user-${row.toolkit}`}
|
||||
toolkit={row.toolkit}
|
||||
connected={row.user}
|
||||
canConnect
|
||||
canDisconnect
|
||||
busy={busy === `${row.toolkit}:user`}
|
||||
onConnect={() => connect(row.toolkit, "user")}
|
||||
onDisconnect={() => disconnect(row.toolkit, "user")}
|
||||
/>
|
||||
))
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-1 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
</Section>
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<RowSkeleton />
|
||||
<RowSkeleton />
|
||||
<RowSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!shared && isStaff ? (
|
||||
<form
|
||||
onSubmit={connectCustom}
|
||||
className="flex min-h-[152px] min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Custom MCP server
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{apps.map((entry) => (
|
||||
<AppCard
|
||||
key={`${scope}-${entry.slug}`}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
/>
|
||||
))}
|
||||
{!shared &&
|
||||
customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
connected
|
||||
canConnect={false}
|
||||
canDisconnect
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
29
apps/web/components/settings/proactiveness-icon.tsx
Normal file
29
apps/web/components/settings/proactiveness-icon.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export function ProactivenessIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
26
apps/web/components/settings/proactiveness.tsx
Normal file
26
apps/web/components/settings/proactiveness.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import CompanyBrainAutomations from "./company-brain-automations"
|
||||
|
||||
export default function Proactiveness() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
if (!isCompanyBrain) {
|
||||
return (
|
||||
<div className="px-1 pt-2">
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
|
||||
Company Brain isn't enabled for this organization.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CompanyBrainAutomations />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,12 +11,15 @@ import Billing from "@/components/settings/billing"
|
|||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
|
|
@ -52,6 +55,7 @@ export const TABS = [
|
|||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"proactiveness",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -91,9 +95,15 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "GitHub & Linear — org and personal",
|
||||
description: "Connect apps to your brain — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "proactiveness",
|
||||
label: "Proactiveness",
|
||||
description: "Scheduled digests and unprompted actions",
|
||||
icon: <ProactivenessIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
|
|
@ -154,6 +164,14 @@ export function SettingsContent({
|
|||
showIdentity?: boolean
|
||||
}) {
|
||||
const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
|
||||
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
// Company Brain orgs manage tools inside Company Brain; hide the generic tabs.
|
||||
const navItems = isCompanyBrain
|
||||
? NAV_ITEMS.filter(
|
||||
(item) => item.id !== "integrations" && item.id !== "connections",
|
||||
)
|
||||
: NAV_ITEMS
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
|
@ -307,7 +325,7 @@ export function SettingsContent({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{navItems.map((item) => {
|
||||
const isExternal = item.id === "integrations"
|
||||
const isActive = !isExternal && activeTab === item.id
|
||||
return (
|
||||
|
|
@ -481,6 +499,7 @@ export function SettingsContent({
|
|||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "proactiveness" && <Proactiveness />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
|
@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
|
|||
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
||||
if (doc.source === "mcp")
|
||||
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
||||
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
||||
if (isYouTubeUrl(doc.url))
|
||||
return {
|
||||
label: "YouTube Videos",
|
||||
singularLabel: "YouTube Video",
|
||||
|
|
|
|||
|
|
@ -12,12 +12,23 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
|
||||
import {
|
||||
LogOut,
|
||||
Settings,
|
||||
RotateCcw,
|
||||
HelpCircle,
|
||||
LifeBuoy,
|
||||
Building2,
|
||||
Sun,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
export function UserProfileMenu({
|
||||
className,
|
||||
|
|
@ -31,6 +42,8 @@ export function UserProfileMenu({
|
|||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
|
||||
|
|
@ -152,6 +165,31 @@ export function UserProfileMenu({
|
|||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("company-brain")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
Company Brain
|
||||
</DropdownMenuItem>
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("proactiveness")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<ProactivenessIcon className="size-4 text-[#737373]" />
|
||||
Proactiveness
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("integrations")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Sun className="size-4 text-[#737373]" />
|
||||
Integrations
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onClick={handleTryOnboarding}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
export function isYouTubeUrl(url: string | undefined | null): boolean {
|
||||
if (!url) return false
|
||||
return (
|
||||
url.includes("youtube.com") ||
|
||||
url.includes("youtu.be") ||
|
||||
url.includes("m.youtube.com")
|
||||
)
|
||||
}
|
||||
export { isYouTubeUrl }
|
||||
|
||||
export function extractYouTubeVideoId(
|
||||
url: string | undefined | null,
|
||||
|
|
|
|||
19
apps/web/hooks/use-connector-access.ts
Normal file
19
apps/web/hooks/use-connector-access.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
// Connector entitlement (pro tier or company_brain) — mirrors backend canAccessConnector. Not for plugins.
|
||||
export function useConnectorAccess(opts?: { enabled?: boolean }) {
|
||||
const enabled = opts?.enabled ?? true
|
||||
const autumn = useCustomer({ queryOptions: { enabled } })
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
return {
|
||||
hasPro,
|
||||
hasMax,
|
||||
hasCompanyBrain,
|
||||
connectorAccess: hasPro || hasCompanyBrain,
|
||||
loading: enabled && autumn.isLoading,
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { $fetch } from "@lib/api"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
|
||||
import { getBackendUrl } from "@/lib/url-helpers"
|
||||
|
||||
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
|
||||
function fetchErrorMessage(err: unknown, fallback: string): string {
|
||||
|
|
@ -500,14 +501,11 @@ export function useDocumentMutations({
|
|||
}
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
const response = await fetch(`${getBackendUrl()}/v3/documents/file`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Failed to upload file"
|
||||
|
|
|
|||
109
apps/web/hooks/use-inferred-memories.ts
Normal file
109
apps/web/hooks/use-inferred-memories.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export type InferredMemory = {
|
||||
id: string
|
||||
memory: string
|
||||
parentCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export type ReviewAction = "approve" | "decline" | "undo"
|
||||
|
||||
const inferredKey = (containerTag: string | undefined) =>
|
||||
["inferred-memories", containerTag] as const
|
||||
|
||||
// Inferred memories the engine wasn't fully confident about, awaiting review.
|
||||
export function useInferredMemories(containerTag: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: inferredKey(containerTag),
|
||||
queryFn: async (): Promise<InferredMemory[]> => {
|
||||
if (!containerTag) return []
|
||||
const res = await $fetch("@get/container-tags/:containerTag/inferred", {
|
||||
params: { containerTag },
|
||||
})
|
||||
if (res.error) throw new Error(res.error?.message ?? "Failed to load")
|
||||
return res.data?.memories ?? []
|
||||
},
|
||||
enabled: !!containerTag,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useReviewInferredMemory(containerTag: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
memoryId,
|
||||
action,
|
||||
}: {
|
||||
memoryId: string
|
||||
action: ReviewAction
|
||||
}) => {
|
||||
if (!containerTag) throw new Error("Missing container tag")
|
||||
const res = await $fetch(
|
||||
"@post/container-tags/:containerTag/inferred/:memoryId/review",
|
||||
{ params: { containerTag, memoryId }, body: { action } },
|
||||
)
|
||||
if (res.error) throw new Error(res.error?.message ?? "Review failed")
|
||||
return res.data
|
||||
},
|
||||
// The modal pops cards off its local stack as you swipe, so we just drop
|
||||
// the reviewed entry from the cached queue once the request settles.
|
||||
// Undo restores the memory server-side, so refetch to bring it back.
|
||||
onSuccess: (_data, { memoryId, action }) => {
|
||||
if (action === "undo") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inferredKey(containerTag),
|
||||
})
|
||||
return
|
||||
}
|
||||
queryClient.setQueryData<InferredMemory[]>(
|
||||
inferredKey(containerTag),
|
||||
(prev) => prev?.filter((m) => m.id !== memoryId),
|
||||
)
|
||||
},
|
||||
// The card already flew off the stack, so a silent failure would read as
|
||||
// "saved". Surface it; the entry stays cached (onSuccess never ran) so it
|
||||
// resurfaces for review later.
|
||||
onError: (_err, { action }) => {
|
||||
toast.error(
|
||||
action === "undo"
|
||||
? "Couldn't undo that review. Try again."
|
||||
: "Couldn't save your review. It'll resurface for review later.",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Session-local queue edits for decisions that don't hit the server (skip),
|
||||
// so the trigger's live count reflects them and the prompt can be dismissed.
|
||||
export function useInferredMemoryCache(containerTag: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
const drop = useCallback(
|
||||
(memoryId: string) =>
|
||||
queryClient.setQueryData<InferredMemory[]>(
|
||||
inferredKey(containerTag),
|
||||
(prev) => prev?.filter((m) => m.id !== memoryId),
|
||||
),
|
||||
[queryClient, containerTag],
|
||||
)
|
||||
const restore = useCallback(
|
||||
(memory: InferredMemory) =>
|
||||
queryClient.setQueryData<InferredMemory[]>(
|
||||
inferredKey(containerTag),
|
||||
(prev) =>
|
||||
prev?.some((m) => m.id === memory.id)
|
||||
? prev
|
||||
: [...(prev ?? []), memory],
|
||||
),
|
||||
[queryClient, containerTag],
|
||||
)
|
||||
return { drop, restore }
|
||||
}
|
||||
|
|
@ -37,12 +37,20 @@ export function useProcessingDocuments() {
|
|||
staleTime: 0,
|
||||
})
|
||||
|
||||
const docs =
|
||||
(
|
||||
data as
|
||||
| { documents?: Array<{ id?: string | null; status?: string | null }> }
|
||||
| undefined
|
||||
)?.documents ?? []
|
||||
// Memoized on `data` (kept referentially stable between polls by React
|
||||
// Query's structural sharing) so `processingMap` only changes identity
|
||||
// when the poll payload actually changes — the effect below depends on it.
|
||||
const docs = useMemo(
|
||||
() =>
|
||||
(
|
||||
data as
|
||||
| {
|
||||
documents?: Array<{ id?: string | null; status?: string | null }>
|
||||
}
|
||||
| undefined
|
||||
)?.documents ?? [],
|
||||
[data],
|
||||
)
|
||||
|
||||
const processingMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
|
|
@ -52,7 +60,6 @@ export function useProcessingDocuments() {
|
|||
}
|
||||
}
|
||||
return map
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docs])
|
||||
|
||||
// Detect docs that just finished (present in previous poll, absent now).
|
||||
|
|
@ -80,8 +87,10 @@ export function useProcessingDocuments() {
|
|||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processingMap.keys, queryClient.refetchQueries])
|
||||
// `processingMap` (not `processingMap.keys` — that's the shared
|
||||
// Map.prototype method, identical for every map, so the effect would
|
||||
// never re-run and finished docs would never trigger a refresh).
|
||||
}, [processingMap, queryClient])
|
||||
|
||||
return processingMap
|
||||
}
|
||||
|
|
|
|||
64
apps/web/hooks/use-research-status.ts
Normal file
64
apps/web/hooks/use-research-status.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
const MAX_POLLS = 150
|
||||
|
||||
export type ResearchStat = { label: string; value: string }
|
||||
|
||||
export type ResearchEvent = {
|
||||
aspect: string
|
||||
label: string
|
||||
status: "in_progress" | "complete" | "error" | string
|
||||
detail: string | null
|
||||
stats: ResearchStat[]
|
||||
highlights: string[]
|
||||
sources: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type ResearchState = {
|
||||
status: "queued" | "running" | "done" | null
|
||||
domain: string | null
|
||||
findings: number
|
||||
events: ResearchEvent[]
|
||||
}
|
||||
|
||||
const EMPTY: ResearchState = {
|
||||
status: null,
|
||||
domain: null,
|
||||
findings: 0,
|
||||
events: [],
|
||||
}
|
||||
|
||||
export function useResearchStatus(enabled = true) {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id
|
||||
|
||||
const { data } = useQuery<ResearchState>({
|
||||
queryKey: ["brain-research-status", orgId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/research/status`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
})
|
||||
if (!res.ok) return EMPTY
|
||||
return (await res.json()) as ResearchState
|
||||
},
|
||||
enabled: Boolean(enabled && orgId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status
|
||||
const polls = query.state.dataUpdateCount
|
||||
if (status === "done" || polls >= MAX_POLLS) return false
|
||||
return POLL_INTERVAL_MS
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
return data ?? EMPTY
|
||||
}
|
||||
|
|
@ -222,7 +222,14 @@ export const analytics = {
|
|||
|
||||
// settings / spaces / docs analytics
|
||||
settingsTabChanged: (props: {
|
||||
tab: "account" | "billing" | "integrations" | "connections" | "support"
|
||||
tab:
|
||||
| "account"
|
||||
| "billing"
|
||||
| "integrations"
|
||||
| "connections"
|
||||
| "company-brain"
|
||||
| "proactiveness"
|
||||
| "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
||||
spaceCreated: () => safeCapture("space_created"),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,26 @@ export function getSignupSource(
|
|||
: null
|
||||
}
|
||||
|
||||
// Company domain captured during team onboarding.
|
||||
export function getBrainWorkspaceDomain(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): string | null {
|
||||
if (!metadataRaw) return null
|
||||
let metadata: Record<string, unknown>
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
metadata = metadataRaw
|
||||
}
|
||||
return typeof metadata.brainWorkspaceDomain === "string"
|
||||
? (metadata.brainWorkspaceDomain as string)
|
||||
: null
|
||||
}
|
||||
|
||||
// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously
|
||||
// at org creation, so it's the reliable pre-webhook signal for company brain.
|
||||
export function getBrainMode(
|
||||
|
|
|
|||
69
apps/web/lib/extract-urls.test.ts
Normal file
69
apps/web/lib/extract-urls.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { extractUrls } from "./url-helpers"
|
||||
|
||||
describe("extractUrls", () => {
|
||||
it("extracts bare, markdown, and angle-bracket links", () => {
|
||||
const { urls } = extractUrls(
|
||||
"see https://a.example/one, [docs](https://b.example/two) and <https://c.example/three>",
|
||||
)
|
||||
expect(urls).toEqual([
|
||||
"https://a.example/one",
|
||||
"https://b.example/two",
|
||||
"https://c.example/three",
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes scheme-less URLs", () => {
|
||||
const { urls } = extractUrls("check supermemory.ai for details")
|
||||
expect(urls).toEqual(["https://supermemory.ai"])
|
||||
})
|
||||
|
||||
it("does not extract URLs from email addresses", () => {
|
||||
const result = extractUrls("email me at john.doe@example.com")
|
||||
expect(result.urls).toEqual([])
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps real URLs while skipping emails in the same text", () => {
|
||||
const { urls } = extractUrls(
|
||||
"email john.doe@example.com or visit https://supermemory.ai",
|
||||
)
|
||||
expect(urls).toEqual(["https://supermemory.ai"])
|
||||
})
|
||||
|
||||
it("skips multiple email addresses", () => {
|
||||
const { urls } = extractUrls(
|
||||
"contacts: a.person@foo.example, b.person@bar.example",
|
||||
)
|
||||
expect(urls).toEqual([])
|
||||
})
|
||||
|
||||
it("strips trailing punctuation", () => {
|
||||
const { urls } = extractUrls("read https://example.com/post.")
|
||||
expect(urls).toEqual(["https://example.com/post"])
|
||||
})
|
||||
|
||||
it("dedupes URLs that differ only by scheme/host case or trailing slash", () => {
|
||||
const { urls, duplicates } = extractUrls(
|
||||
"HTTPS://EXAMPLE.COM/docs https://example.com/docs https://example.com/docs/",
|
||||
)
|
||||
expect(urls).toHaveLength(1)
|
||||
expect(duplicates).toBe(2)
|
||||
})
|
||||
|
||||
it("keeps URLs whose paths differ only by case", () => {
|
||||
const { urls, duplicates } = extractUrls(
|
||||
"https://example.com/Page and https://example.com/page",
|
||||
)
|
||||
expect(urls).toEqual([
|
||||
"https://example.com/Page",
|
||||
"https://example.com/page",
|
||||
])
|
||||
expect(duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it("returns nothing for plain text", () => {
|
||||
expect(extractUrls("no links here").urls).toEqual([])
|
||||
expect(extractUrls("").urls).toEqual([])
|
||||
})
|
||||
})
|
||||
86
apps/web/lib/plugin-document.test.ts
Normal file
86
apps/web/lib/plugin-document.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { parsePluginDocument } from "./plugin-document"
|
||||
|
||||
type PluginDocumentInput = Parameters<typeof parsePluginDocument>[0]
|
||||
|
||||
function makeCodexSessionDocument(content: string): PluginDocumentInput {
|
||||
return {
|
||||
id: "doc_1",
|
||||
title: "Codex session",
|
||||
content,
|
||||
metadata: { sm_source: "codex" },
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput
|
||||
}
|
||||
|
||||
describe("parsePluginDocument — session transcripts", () => {
|
||||
it("keeps multi-line message bodies intact", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
[
|
||||
"[Session abc-123]",
|
||||
"1. [user] Hello there",
|
||||
"Here is more context on line two",
|
||||
"2. [assistant] Sure!",
|
||||
"Second line of the reply",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed?.kind).toBe("codex-session")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe(
|
||||
"Hello there\nHere is more context on line two",
|
||||
)
|
||||
expect(parsed?.messages[1]?.text).toBe("Sure!\nSecond line of the reply")
|
||||
})
|
||||
|
||||
it("surfaces memory id artifacts from continuation lines", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
[
|
||||
"[Session abc-123]",
|
||||
"1. [user] Remember my editor is Neovim",
|
||||
"memory id: mem_456",
|
||||
"2. [assistant] Saved it.",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages[0]?.text).toBe("Remember my editor is Neovim")
|
||||
expect(parsed?.artifacts).toContainEqual({
|
||||
label: "Memory ID",
|
||||
value: "mem_456",
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes literal \\n escapes before splitting messages", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
"[Session abc-123]\\n1. [user] First line\\nSecond line\\n2. [assistant] Reply",
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe("First line\nSecond line")
|
||||
expect(parsed?.messages[1]?.text).toBe("Reply")
|
||||
})
|
||||
|
||||
it("parses single-line messages as before", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
["[Session abc-123]", "1. [user] Hi", "2. [assistant] Hello!"].join(
|
||||
"\n",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe("Hi")
|
||||
expect(parsed?.messages[1]?.text).toBe("Hello!")
|
||||
expect(parsed?.summary).toBe(
|
||||
"1 user message and 1 assistant message captured from Codex.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -227,8 +227,11 @@ function parseTranscriptMessages(content: string): {
|
|||
} {
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const artifacts: PluginArtifact[] = []
|
||||
// The lookahead must end the last message at the true end of input:
|
||||
// with the m flag, a bare $ matches every line end and would cut each
|
||||
// message body off at its first newline.
|
||||
const regex = new RegExp(
|
||||
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|$)`,
|
||||
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|(?![\\s\\S]))`,
|
||||
"gm",
|
||||
)
|
||||
|
||||
|
|
|
|||
73
apps/web/lib/url-helpers.test.ts
Normal file
73
apps/web/lib/url-helpers.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { isYouTubeUrl } from "./url-helpers"
|
||||
|
||||
describe("isYouTubeUrl", () => {
|
||||
it("matches canonical youtube.com watch URLs", () => {
|
||||
expect(isYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches real youtube subdomains", () => {
|
||||
expect(isYouTubeUrl("https://m.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches youtu.be short links", () => {
|
||||
expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches embed and shorts paths", () => {
|
||||
expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("is case-insensitive for scheme and host", () => {
|
||||
expect(isYouTubeUrl("HTTPS://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches scheme-less URLs", () => {
|
||||
expect(isYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects lookalike domains", () => {
|
||||
expect(isYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isYouTubeUrl("https://myyoutu.be/dQw4w9WgXcQ")).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects hosts that merely start with youtube.com", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://youtu.be.evil.example/dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects URLs that only contain youtube.com in the path", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://evil.example/redirect?to=youtu.be/x")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects empty and nullish input", () => {
|
||||
expect(isYouTubeUrl("")).toBe(false)
|
||||
expect(isYouTubeUrl(null)).toBe(false)
|
||||
expect(isYouTubeUrl(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
const DEFAULT_BACKEND_URL = "https://api.supermemory.ai"
|
||||
const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai"
|
||||
const PROD_APP_ORIGIN = "https://app.supermemory.ai"
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
return (process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_BACKEND_URL).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
export function getAppOriginForCurrentEnvironment(hostname?: string): string {
|
||||
const currentHostname =
|
||||
hostname ?? (typeof window !== "undefined" ? window.location.hostname : "")
|
||||
|
|
@ -96,12 +104,20 @@ export const extractUrls = (
|
|||
const unwrapped = text
|
||||
.replace(MARKDOWN_LINK_REGEX, " $1 ")
|
||||
.replace(ANGLE_LINK_REGEX, " $1 ")
|
||||
const matches = unwrapped.match(URL_TOKEN_REGEX) ?? []
|
||||
const seen = new Set<string>()
|
||||
const urls: string[] = []
|
||||
let duplicates = 0
|
||||
for (const match of matches) {
|
||||
let trimmed = match.trim().replace(/[.,;!]+$/, "")
|
||||
for (const match of unwrapped.matchAll(URL_TOKEN_REGEX)) {
|
||||
const start = match.index ?? 0
|
||||
const end = start + match[0].length
|
||||
const before = start > 0 ? (unwrapped[start - 1] ?? "") : ""
|
||||
const after = end < unwrapped.length ? (unwrapped[end] ?? "") : ""
|
||||
// Skip email addresses: a domain-shaped token ending at "@" is the
|
||||
// local part, one starting right after "@" is the mail domain. Also
|
||||
// skip matches that begin mid-token (e.g. after "_", which the
|
||||
// hostname charset can't include) — those aren't standalone URLs.
|
||||
if (after === "@" || before === "@" || /[\w.-]/.test(before)) continue
|
||||
let trimmed = match[0].trim().replace(/[.,;!]+$/, "")
|
||||
const opens = (trimmed.match(/\(/g) ?? []).length
|
||||
const closes = (trimmed.match(/\)/g) ?? []).length
|
||||
if (closes > opens && trimmed.endsWith(")")) {
|
||||
|
|
@ -109,7 +125,15 @@ export const extractUrls = (
|
|||
}
|
||||
const normalized = normalizeUrl(trimmed)
|
||||
if (!isValidUrl(normalized)) continue
|
||||
const key = normalized.toLowerCase().replace(/\/+$/, "")
|
||||
// Dedupe on the parsed URL so the scheme and host compare
|
||||
// case-insensitively while the path/query — which are case-sensitive
|
||||
// resources — stay distinct.
|
||||
const parsed = new URL(normalized)
|
||||
const key =
|
||||
`${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`.replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
)
|
||||
if (seen.has(key)) {
|
||||
duplicates++
|
||||
continue
|
||||
|
|
@ -157,6 +181,22 @@ export const isTwitterUrl = (url: string): boolean => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a YouTube URL by matching the hostname against
|
||||
* youtube.com / youtu.be (and their subdomains, e.g. www / m).
|
||||
* Lookalike hosts (`notyoutube.com`, `youtube.com.evil.example`) and URLs
|
||||
* that only contain "youtube.com" in the path do not match.
|
||||
*/
|
||||
export const isYouTubeUrl = (url: string | undefined | null): boolean => {
|
||||
if (!url) return false
|
||||
const parsed = parseWebUrl(url)
|
||||
if (!parsed) return false
|
||||
return (
|
||||
hostnameMatches(parsed.hostname, "youtube.com") ||
|
||||
hostnameMatches(parsed.hostname, "youtu.be")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a LinkedIn profile URL (not a company page).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { getSessionCookie } from "better-auth/cookies"
|
|||
import { NextResponse } from "next/server"
|
||||
import { getPublicRequestUrl } from "@/lib/url-helpers"
|
||||
|
||||
const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
|
||||
function getAuthSessionCookie(request: Request): string | null {
|
||||
return (
|
||||
getSessionCookie(request) ??
|
||||
|
|
@ -16,6 +18,18 @@ export default async function proxy(request: Request) {
|
|||
console.debug("[PROXY] Path:", url.pathname)
|
||||
console.debug("[PROXY] Method:", request.method)
|
||||
|
||||
// Development builds only: getPublicRequestUrl trusts x-forwarded-host, so
|
||||
// a hostname check alone could be spoofed in production to skip the /api
|
||||
// 401 gate below. NODE_ENV is inlined at build time, making this dead code
|
||||
// in production bundles.
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
LOCAL_DEV_HOSTS.has(url.hostname)
|
||||
) {
|
||||
console.debug("[PROXY] Local dev host, allowing access")
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sessionCookie = getAuthSessionCookie(request)
|
||||
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
|
||||
|
||||
|
|
|
|||
|
|
@ -166,20 +166,26 @@ class SupermemoryCartesiaAgent:
|
|||
timeout=10.0
|
||||
)
|
||||
|
||||
static_count = len(response.profile.static) if response.profile.static else 0
|
||||
dynamic_count = len(response.profile.dynamic) if response.profile.dynamic else 0
|
||||
search_count = len(response.search_results.results) if response.search_results and response.search_results.results else 0
|
||||
|
||||
logger.info(f"[Supermemory] Retrieved memories - static: {static_count}, dynamic: {dynamic_count}, search: {search_count}")
|
||||
# A user with no stored memories yet gets a null profile back, which
|
||||
# is a normal case, not an error. Guard against it so we return an
|
||||
# empty profile instead of raising AttributeError on response.profile.
|
||||
profile = getattr(response, "profile", None)
|
||||
profile_static = profile.static if profile is not None and profile.static else []
|
||||
profile_dynamic = profile.dynamic if profile is not None and profile.dynamic else []
|
||||
|
||||
search_results = []
|
||||
if response.search_results and response.search_results.results:
|
||||
search_results = response.search_results.results
|
||||
|
||||
logger.info(
|
||||
f"[Supermemory] Retrieved memories - static: {len(profile_static)}, "
|
||||
f"dynamic: {len(profile_dynamic)}, search: {len(search_results)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"profile": {
|
||||
"static": response.profile.static or [],
|
||||
"dynamic": response.profile.dynamic or [],
|
||||
"static": profile_static,
|
||||
"dynamic": profile_dynamic,
|
||||
},
|
||||
"search_results": search_results,
|
||||
}
|
||||
|
|
|
|||
0
packages/cartesia-sdk-python/tests/__init__.py
Normal file
0
packages/cartesia-sdk-python/tests/__init__.py
Normal file
77
packages/cartesia-sdk-python/tests/test_empty_profile.py
Normal file
77
packages/cartesia-sdk-python/tests/test_empty_profile.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
def _install_test_stubs() -> None:
|
||||
if "loguru" not in sys.modules:
|
||||
loguru_module = types.ModuleType("loguru")
|
||||
|
||||
class _Logger:
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
loguru_module.logger = _Logger()
|
||||
sys.modules["loguru"] = loguru_module
|
||||
|
||||
if "pydantic" not in sys.modules:
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
|
||||
class BaseModel:
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def Field(*, default=None, **_kwargs):
|
||||
return default
|
||||
|
||||
pydantic_module.BaseModel = BaseModel
|
||||
pydantic_module.Field = Field
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
|
||||
_install_test_stubs()
|
||||
|
||||
from supermemory_cartesia.agent import SupermemoryCartesiaAgent
|
||||
|
||||
|
||||
class _MockSupermemoryClient:
|
||||
def __init__(self, response):
|
||||
self.profile = AsyncMock(return_value=response)
|
||||
|
||||
|
||||
class TestSupermemoryCartesiaNullProfile(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_retrieve_memories_handles_null_profile(self) -> None:
|
||||
agent = SupermemoryCartesiaAgent(
|
||||
agent=SimpleNamespace(),
|
||||
api_key="mock_key",
|
||||
container_tag="user-123",
|
||||
custom_id="conversation-456",
|
||||
)
|
||||
|
||||
response = SimpleNamespace(profile=None, search_results=None)
|
||||
agent._supermemory_client = _MockSupermemoryClient(response)
|
||||
|
||||
result = await agent._retrieve_memories("Hello world")
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"profile": {"static": [], "dynamic": []},
|
||||
"search_results": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -57,6 +57,34 @@ const WaitlistStatusResponseSchema = z.object({
|
|||
})
|
||||
|
||||
export const apiSchema = createSchema({
|
||||
// Inferred-memory review queue (Nova "Suggested for you")
|
||||
"@get/container-tags/:containerTag/inferred": {
|
||||
output: z.object({
|
||||
memories: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
parentCount: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
}),
|
||||
),
|
||||
total: z.number(),
|
||||
}),
|
||||
params: z.object({ containerTag: z.string() }),
|
||||
},
|
||||
|
||||
"@post/container-tags/:containerTag/inferred/:memoryId/review": {
|
||||
input: z.object({ action: z.enum(["approve", "decline", "undo"]) }),
|
||||
output: z.object({
|
||||
id: z.string(),
|
||||
isInference: z.boolean(),
|
||||
reviewStatus: z.enum(["approved", "declined"]).nullable(),
|
||||
}),
|
||||
params: z.object({ containerTag: z.string(), memoryId: z.string() }),
|
||||
},
|
||||
|
||||
"@get/analytics/chat": {
|
||||
output: AnalyticsChatResponseSchema,
|
||||
query: AnalyticsRequestSchema,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { customAlphabet } from "nanoid"
|
||||
|
||||
export const generateId = () =>
|
||||
customAlphabet("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")(
|
||||
22,
|
||||
)
|
||||
const generate = customAlphabet(
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
|
||||
)
|
||||
|
||||
export const generateId = () => generate(22)
|
||||
|
|
|
|||
154
packages/memory-graph/src/__tests__/labels-layering.test.ts
Normal file
154
packages/memory-graph/src/__tests__/labels-layering.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Tests for configurable labels and hover popover layering.
|
||||
*
|
||||
* Follows the same pattern as node-hover-popover.test.tsx: source-code
|
||||
* assertions plus pure-logic tests on the exported defaults and merge
|
||||
* behaviour, since hook-bearing components can't be mounted in this
|
||||
* workspace's vitest environment.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
|
||||
import type { MemoryGraphLabels, ResolvedMemoryGraphLabels } from "../types"
|
||||
|
||||
const popoverSrc = readFileSync(
|
||||
resolve(__dirname, "../components/node-hover-popover.tsx"),
|
||||
"utf-8",
|
||||
)
|
||||
const legendSrc = readFileSync(
|
||||
resolve(__dirname, "../components/legend.tsx"),
|
||||
"utf-8",
|
||||
)
|
||||
const graphSrc = readFileSync(
|
||||
resolve(__dirname, "../components/memory-graph.tsx"),
|
||||
"utf-8",
|
||||
)
|
||||
const indexSrc = readFileSync(resolve(__dirname, "../index.tsx"), "utf-8")
|
||||
const loadingSrc = readFileSync(
|
||||
resolve(__dirname, "../components/loading-indicator.tsx"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
describe("DEFAULT_LABELS — preserves original copy", () => {
|
||||
it("matches every previously hardcoded string", () => {
|
||||
expect(DEFAULT_LABELS.documentGroup).toBe("Documents")
|
||||
expect(DEFAULT_LABELS.documentTypeFallback).toBe("document")
|
||||
expect(DEFAULT_LABELS.documentSourceEdge).toBe("Document source")
|
||||
expect(DEFAULT_LABELS.documentToMemoryEdge).toBe("Document to memory")
|
||||
expect(DEFAULT_LABELS.documentIdLabel).toBe("Document")
|
||||
expect(DEFAULT_LABELS.viewDocument).toBe("View document")
|
||||
expect(DEFAULT_LABELS.goToDocument).toBe("Go to document")
|
||||
expect(DEFAULT_LABELS.nextDocument).toBe("Next document")
|
||||
expect(DEFAULT_LABELS.previousDocument).toBe("Prev document")
|
||||
expect(DEFAULT_LABELS.showMemory).toBe("Go to memory")
|
||||
})
|
||||
|
||||
it("memoryCount formats counts like the original template", () => {
|
||||
expect(DEFAULT_LABELS.memoryCount(0)).toBe("0 memories")
|
||||
expect(DEFAULT_LABELS.memoryCount(5)).toBe("5 memories")
|
||||
})
|
||||
|
||||
it("loadingMoreDocuments formats counts like the original template", () => {
|
||||
expect(DEFAULT_LABELS.loadingMoreDocuments(12)).toBe(
|
||||
"Loading more documents... (12)",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("label merge — partial overrides win, defaults fill the rest", () => {
|
||||
it("spread merge overrides only the provided keys", () => {
|
||||
const overrides: MemoryGraphLabels = {
|
||||
documentGroup: "Sources",
|
||||
viewDocument: "View source",
|
||||
}
|
||||
const resolved: ResolvedMemoryGraphLabels = {
|
||||
...DEFAULT_LABELS,
|
||||
...overrides,
|
||||
}
|
||||
expect(resolved.documentGroup).toBe("Sources")
|
||||
expect(resolved.viewDocument).toBe("View source")
|
||||
expect(resolved.nextDocument).toBe("Next document")
|
||||
expect(resolved.memoryCount(3)).toBe("3 memories")
|
||||
})
|
||||
|
||||
it("custom memoryCount function replaces the default", () => {
|
||||
const resolved: ResolvedMemoryGraphLabels = {
|
||||
...DEFAULT_LABELS,
|
||||
memoryCount: (count) => `${count} facts`,
|
||||
}
|
||||
expect(resolved.memoryCount(2)).toBe("2 facts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hover popover layering", () => {
|
||||
it("default z-index stays at the previous hardcoded value", () => {
|
||||
expect(DEFAULT_HOVER_POPOVER_Z_INDEX).toBe(100)
|
||||
})
|
||||
|
||||
it("popover overlay uses the zIndex prop, not a literal", () => {
|
||||
expect(popoverSrc).not.toContain("zIndex: 100")
|
||||
const overlayIdx = popoverSrc.indexOf("const overlayStyle")
|
||||
const overlayEnd = popoverSrc.indexOf("}", overlayIdx)
|
||||
expect(popoverSrc.slice(overlayIdx, overlayEnd)).toContain("zIndex,")
|
||||
})
|
||||
|
||||
it("popover defaults zIndex to DEFAULT_HOVER_POPOVER_Z_INDEX", () => {
|
||||
expect(popoverSrc).toContain("zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX")
|
||||
})
|
||||
|
||||
it("MemoryGraph resolves layering.hoverPopoverZIndex with fallback", () => {
|
||||
expect(graphSrc).toContain(
|
||||
"layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX",
|
||||
)
|
||||
expect(graphSrc).toContain("zIndex={hoverPopoverZIndex}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("no user-facing document strings remain hardcoded", () => {
|
||||
it("popover uses labels for all document-facing copy", () => {
|
||||
expect(popoverSrc).not.toContain('"View document"')
|
||||
expect(popoverSrc).not.toContain('"Go to document"')
|
||||
expect(popoverSrc).not.toContain('"Next document"')
|
||||
expect(popoverSrc).not.toContain('"Prev document"')
|
||||
expect(popoverSrc).not.toContain('label="Document"')
|
||||
expect(popoverSrc).not.toContain('|| "document"')
|
||||
expect(popoverSrc).toContain("labels.documentTypeFallback")
|
||||
expect(popoverSrc).toContain("labels.memoryCount(")
|
||||
expect(popoverSrc).toContain("labels.documentIdLabel")
|
||||
expect(popoverSrc).toContain("labels.viewDocument")
|
||||
expect(popoverSrc).toContain("labels.goToDocument")
|
||||
expect(popoverSrc).toContain("labels.showMemory")
|
||||
expect(popoverSrc).toContain("labels.nextDocument")
|
||||
expect(popoverSrc).toContain("labels.previousDocument")
|
||||
})
|
||||
|
||||
it("legend uses labels for group and edge copy", () => {
|
||||
expect(legendSrc).not.toContain('label="Documents"')
|
||||
expect(legendSrc).not.toContain("Document source</span>")
|
||||
expect(legendSrc).not.toContain("Document to memory")
|
||||
expect(legendSrc).toContain("labels.documentGroup")
|
||||
expect(legendSrc).toContain("labels.documentSourceEdge")
|
||||
expect(legendSrc).toContain("labels.documentToMemoryEdge")
|
||||
})
|
||||
|
||||
it("loading indicator uses labels for the loading-more copy", () => {
|
||||
expect(loadingSrc).not.toContain("Loading more documents")
|
||||
expect(loadingSrc).toContain("labels.loadingMoreDocuments(totalLoaded)")
|
||||
})
|
||||
|
||||
it("MemoryGraph merges label overrides and passes them down", () => {
|
||||
expect(graphSrc).toContain("{ ...DEFAULT_LABELS, ...labelOverrides }")
|
||||
expect(graphSrc).toContain("labels={resolvedLabels}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("public API exports", () => {
|
||||
it("index exports the new constants and types", () => {
|
||||
expect(indexSrc).toContain("DEFAULT_LABELS")
|
||||
expect(indexSrc).toContain("DEFAULT_HOVER_POPOVER_Z_INDEX")
|
||||
expect(indexSrc).toContain("MemoryGraphLabels")
|
||||
expect(indexSrc).toContain("MemoryGraphLayering")
|
||||
})
|
||||
})
|
||||
|
|
@ -209,8 +209,8 @@ describe("'View document' button — render guard in JSX", () => {
|
|||
expect(src).toContain("onClick={() => onOpenDocument(documentId)}")
|
||||
})
|
||||
|
||||
it("button label text is 'View document'", () => {
|
||||
expect(src).toContain('label="View document"')
|
||||
it("button label comes from configurable labels", () => {
|
||||
expect(src).toContain("label={labels.viewDocument}")
|
||||
})
|
||||
|
||||
it("button icon is the EyeIcon component (not a string shortcut key)", () => {
|
||||
|
|
|
|||
101
packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx
Normal file
101
packages/memory-graph/src/__tests__/popover-render.e2e.test.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Mounted-render verification for configurable labels and popover layering.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
afterEach(cleanup)
|
||||
import { NodeHoverPopover } from "../components/node-hover-popover"
|
||||
import { DEFAULT_COLORS, DEFAULT_LABELS } from "../constants"
|
||||
import type { GraphNode, ResolvedMemoryGraphLabels } from "../types"
|
||||
|
||||
const documentNode: GraphNode = {
|
||||
id: "doc-1",
|
||||
type: "document",
|
||||
x: 0,
|
||||
y: 0,
|
||||
data: {
|
||||
id: "doc-1",
|
||||
title: "Doc title",
|
||||
summary: "Doc summary",
|
||||
type: "",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
memories: [],
|
||||
},
|
||||
size: 40,
|
||||
borderColor: "#fff",
|
||||
isHovered: false,
|
||||
isDragging: false,
|
||||
}
|
||||
|
||||
function renderPopover(
|
||||
labels?: ResolvedMemoryGraphLabels,
|
||||
zIndex?: number,
|
||||
onOpenDocument?: (id: string) => void,
|
||||
) {
|
||||
return render(
|
||||
<NodeHoverPopover
|
||||
colors={DEFAULT_COLORS}
|
||||
labels={labels}
|
||||
node={documentNode}
|
||||
nodeRadius={20}
|
||||
screenX={100}
|
||||
screenY={100}
|
||||
zIndex={zIndex}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("NodeHoverPopover mounted render", () => {
|
||||
it("renders default document copy when no labels are passed", () => {
|
||||
const { container, getByText } = renderPopover(
|
||||
undefined,
|
||||
undefined,
|
||||
() => {},
|
||||
)
|
||||
getByText("View document")
|
||||
getByText("Go to memory")
|
||||
getByText("Next document")
|
||||
getByText("Prev document")
|
||||
getByText("document")
|
||||
getByText("Document")
|
||||
getByText("0 memories")
|
||||
const overlay = container.firstChild as HTMLElement
|
||||
expect(overlay.style.zIndex).toBe("100")
|
||||
})
|
||||
|
||||
it("renders custom source-facing copy and custom z-index", () => {
|
||||
const labels: ResolvedMemoryGraphLabels = {
|
||||
...DEFAULT_LABELS,
|
||||
documentGroup: "Sources",
|
||||
documentTypeFallback: "source",
|
||||
documentIdLabel: "Source",
|
||||
viewDocument: "View source",
|
||||
goToDocument: "Go to source",
|
||||
nextDocument: "Next source",
|
||||
previousDocument: "Prev source",
|
||||
showMemory: "Show memory",
|
||||
memoryCount: (count) => `${count} facts`,
|
||||
}
|
||||
const { container, getByText, queryByText } = renderPopover(
|
||||
labels,
|
||||
30,
|
||||
() => {},
|
||||
)
|
||||
getByText("View source")
|
||||
getByText("Show memory")
|
||||
getByText("Next source")
|
||||
getByText("Prev source")
|
||||
getByText("source")
|
||||
getByText("Source")
|
||||
getByText("0 facts")
|
||||
expect(queryByText("View document")).toBeNull()
|
||||
const overlay = container.firstChild as HTMLElement
|
||||
expect(overlay.style.zIndex).toBe("30")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
import { memo, useState } from "react"
|
||||
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
|
||||
import { DEFAULT_LABELS } from "../constants"
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphThemeColors,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
|
||||
interface LegendProps {
|
||||
nodes?: GraphNode[]
|
||||
edges?: GraphEdge[]
|
||||
isLoading?: boolean
|
||||
colors: GraphThemeColors
|
||||
labels?: ResolvedMemoryGraphLabels
|
||||
hoveredNode?: string | null
|
||||
compact?: boolean
|
||||
maxHeight?: number
|
||||
|
|
@ -291,6 +298,7 @@ export const Legend = memo(function Legend({
|
|||
edges = [],
|
||||
isLoading: _isLoading = false,
|
||||
colors,
|
||||
labels = DEFAULT_LABELS,
|
||||
hoveredNode,
|
||||
compact = false,
|
||||
maxHeight,
|
||||
|
|
@ -462,7 +470,7 @@ export const Legend = memo(function Legend({
|
|||
}}
|
||||
/>
|
||||
}
|
||||
label="Documents"
|
||||
label={labels.documentGroup}
|
||||
colors={colors}
|
||||
/>
|
||||
<StatRow
|
||||
|
|
@ -523,12 +531,14 @@ export const Legend = memo(function Legend({
|
|||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeDerives} />
|
||||
<span style={edgeLabelStyle}>Document source</span>
|
||||
<span style={edgeLabelStyle}>
|
||||
{labels.documentSourceEdge}
|
||||
</span>
|
||||
</div>
|
||||
<span style={countStyle}>{derivesCount}</span>
|
||||
</div>
|
||||
<div style={edgeDescriptionStyle}>
|
||||
Document to memory
|
||||
{labels.documentToMemoryEdge}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { memo } from "react"
|
||||
import type { GraphThemeColors, LoadingIndicatorProps } from "../types"
|
||||
import { DEFAULT_LABELS } from "../constants"
|
||||
import type {
|
||||
GraphThemeColors,
|
||||
LoadingIndicatorProps,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
|
||||
const spinKeyframes = `
|
||||
@keyframes mg-spin {
|
||||
|
|
@ -18,70 +23,82 @@ function injectSpinStyle() {
|
|||
}
|
||||
|
||||
export const LoadingIndicator = memo<
|
||||
LoadingIndicatorProps & { colors?: GraphThemeColors }
|
||||
>(({ isLoading, isLoadingMore, totalLoaded, colors }) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
injectSpinStyle()
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 30,
|
||||
overflow: "hidden",
|
||||
top: 16,
|
||||
left: 16,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
|
||||
backgroundColor: colors?.controlBg ?? "#1a1f29",
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
|
||||
LoadingIndicatorProps & {
|
||||
colors?: GraphThemeColors
|
||||
labels?: ResolvedMemoryGraphLabels
|
||||
}
|
||||
>(
|
||||
({
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
totalLoaded,
|
||||
colors,
|
||||
labels = DEFAULT_LABELS,
|
||||
}) => {
|
||||
if (!isLoading && !isLoadingMore) return null
|
||||
|
||||
const flexStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
injectSpinStyle()
|
||||
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
animation: "mg-spin 1s linear infinite",
|
||||
color: colors?.accent ?? "#3B73B8",
|
||||
flexShrink: 0,
|
||||
}
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 30,
|
||||
overflow: "hidden",
|
||||
top: 16,
|
||||
left: 16,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
|
||||
backgroundColor: colors?.controlBg ?? "#1a1f29",
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
|
||||
}
|
||||
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: colors?.textSecondary ?? "#e2e8f0",
|
||||
}
|
||||
const flexStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={flexStyle}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
style={spinnerStyle}
|
||||
>
|
||||
<title>Loading</title>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
<span style={textStyle}>
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: `Loading more documents... (${totalLoaded})`}
|
||||
</span>
|
||||
const spinnerStyle: React.CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
animation: "mg-spin 1s linear infinite",
|
||||
color: colors?.accent ?? "#3B73B8",
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: colors?.textSecondary ?? "#e2e8f0",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={flexStyle}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
style={spinnerStyle}
|
||||
>
|
||||
<title>Loading</title>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
<span style={textStyle}>
|
||||
{isLoading
|
||||
? "Loading memory graph..."
|
||||
: labels.loadingMoreDocuments(totalLoaded)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
DENSE_GRAPH_STATIC_THRESHOLD,
|
||||
ForceSimulation,
|
||||
} from "../canvas/simulation"
|
||||
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
|
||||
import { VersionChainIndex } from "../canvas/version-chain"
|
||||
import type { ViewportState } from "../canvas/viewport"
|
||||
import { useGraphData } from "../hooks/use-graph-data"
|
||||
|
|
@ -11,6 +12,7 @@ import type {
|
|||
GraphApiDocument,
|
||||
GraphThemeColors,
|
||||
MemoryGraphProps,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
import { GraphCanvas } from "./graph-canvas"
|
||||
import { Legend } from "./legend"
|
||||
|
|
@ -37,7 +39,15 @@ export function MemoryGraph({
|
|||
colors: colorOverrides,
|
||||
totalCount,
|
||||
onOpenDocument,
|
||||
labels: labelOverrides,
|
||||
layering,
|
||||
}: MemoryGraphProps) {
|
||||
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
|
||||
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
|
||||
[labelOverrides],
|
||||
)
|
||||
const hoverPopoverZIndex =
|
||||
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
|
||||
const resolvedColors = useGraphTheme(colorOverrides)
|
||||
const colors = useMemo<GraphThemeColors>(
|
||||
() =>
|
||||
|
|
@ -397,8 +407,8 @@ export function MemoryGraph({
|
|||
}, [containerSize.width, graphFitHeight])
|
||||
|
||||
// Wrap onOpenDocument to dismiss the popover before opening the modal.
|
||||
// Without this, the popover (z-index: 100) stays mounted on top of the
|
||||
// document modal (z-50), obscuring it and intercepting clicks.
|
||||
// Without this, the popover overlay stays mounted on top of the
|
||||
// document modal, obscuring it and intercepting clicks.
|
||||
const handleOpenDocument = useCallback(
|
||||
(documentId: string) => {
|
||||
setSelectedNode(null)
|
||||
|
|
@ -748,6 +758,7 @@ export function MemoryGraph({
|
|||
<LoadingIndicator
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
labels={resolvedLabels}
|
||||
totalLoaded={totalCount ?? documents.length}
|
||||
colors={colors}
|
||||
/>
|
||||
|
|
@ -783,7 +794,9 @@ export function MemoryGraph({
|
|||
<NodeHoverPopover
|
||||
colors={colors}
|
||||
containerBounds={containerBounds ?? undefined}
|
||||
labels={resolvedLabels}
|
||||
node={activeNodeData}
|
||||
zIndex={hoverPopoverZIndex}
|
||||
nodeRadius={activePopoverPosition.nodeRadius}
|
||||
onNavigateDown={navigateDown}
|
||||
onNavigateNext={navigateNext}
|
||||
|
|
@ -812,6 +825,7 @@ export function MemoryGraph({
|
|||
<Legend
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
labels={resolvedLabels}
|
||||
hoveredNode={hoveredNode}
|
||||
compact={isCompactViewport}
|
||||
isLoading={isLoading}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { ChainEntry } from "../canvas/version-chain"
|
||||
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
|
||||
import type {
|
||||
DocumentNodeData,
|
||||
GraphNode,
|
||||
GraphThemeColors,
|
||||
MemoryNodeData,
|
||||
ResolvedMemoryGraphLabels,
|
||||
} from "../types"
|
||||
|
||||
export interface NodeHoverPopoverProps {
|
||||
|
|
@ -15,6 +17,8 @@ export interface NodeHoverPopoverProps {
|
|||
containerBounds?: DOMRect
|
||||
versionChain?: ChainEntry[] | null
|
||||
colors: GraphThemeColors
|
||||
labels?: ResolvedMemoryGraphLabels
|
||||
zIndex?: number
|
||||
onNavigateNext?: () => void
|
||||
onNavigatePrev?: () => void
|
||||
onNavigateUp?: () => void
|
||||
|
|
@ -310,6 +314,8 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
containerBounds,
|
||||
versionChain,
|
||||
colors,
|
||||
labels = DEFAULT_LABELS,
|
||||
zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
onNavigateUp,
|
||||
|
|
@ -419,7 +425,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 100,
|
||||
zIndex,
|
||||
}
|
||||
|
||||
const svgStyle: React.CSSProperties = {
|
||||
|
|
@ -605,7 +611,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
color: colors.popoverTextSecondary,
|
||||
}}
|
||||
>
|
||||
{docData?.type || "document"}
|
||||
{docData?.type || labels.documentTypeFallback}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -613,7 +619,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
color: colors.popoverTextSecondary,
|
||||
}}
|
||||
>
|
||||
{docData?.memories?.length ?? 0} memories
|
||||
{labels.memoryCount(docData?.memories?.length ?? 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -623,7 +629,11 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
{isMemory ? (
|
||||
<CopyableId colors={colors} label="Memory" value={node.id} />
|
||||
) : (
|
||||
<CopyableId colors={colors} label="Document" value={node.id} />
|
||||
<CopyableId
|
||||
colors={colors}
|
||||
label={labels.documentIdLabel}
|
||||
value={node.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -633,7 +643,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
<NavButton
|
||||
colors={colors}
|
||||
icon={<EyeIcon color={colors.popoverTextMuted} />}
|
||||
label="View document"
|
||||
label={labels.viewDocument}
|
||||
onClick={() => onOpenDocument(documentId)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -641,7 +651,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
<NavButton
|
||||
colors={colors}
|
||||
icon="↑"
|
||||
label={hasChain ? "Older version" : "Go to document"}
|
||||
label={hasChain ? "Older version" : labels.goToDocument}
|
||||
onClick={onNavigateUp}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -649,20 +659,20 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
<NavButton
|
||||
colors={colors}
|
||||
icon="↓"
|
||||
label={isMemory ? "Newer version" : "Go to memory"}
|
||||
label={isMemory ? "Newer version" : labels.showMemory}
|
||||
onClick={onNavigateDown}
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="→"
|
||||
label={isMemory ? "Next memory" : "Next document"}
|
||||
label={isMemory ? "Next memory" : labels.nextDocument}
|
||||
onClick={onNavigateNext}
|
||||
/>
|
||||
<NavButton
|
||||
colors={colors}
|
||||
icon="←"
|
||||
label={isMemory ? "Prev memory" : "Prev document"}
|
||||
label={isMemory ? "Prev memory" : labels.previousDocument}
|
||||
onClick={onNavigatePrev}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
import type { GraphThemeColors } from "./types"
|
||||
import type { GraphThemeColors, ResolvedMemoryGraphLabels } from "./types"
|
||||
|
||||
export const DEFAULT_LABELS: ResolvedMemoryGraphLabels = {
|
||||
documentGroup: "Documents",
|
||||
documentTypeFallback: "document",
|
||||
documentSourceEdge: "Document source",
|
||||
documentToMemoryEdge: "Document to memory",
|
||||
memoryCount: (count: number) => `${count} memories`,
|
||||
documentIdLabel: "Document",
|
||||
viewDocument: "View document",
|
||||
goToDocument: "Go to document",
|
||||
nextDocument: "Next document",
|
||||
previousDocument: "Prev document",
|
||||
showMemory: "Go to memory",
|
||||
loadingMoreDocuments: (count: number) =>
|
||||
`Loading more documents... (${count})`,
|
||||
}
|
||||
|
||||
export const DEFAULT_HOVER_POPOVER_Z_INDEX = 100
|
||||
|
||||
export const MEMORY_BORDER_KEYS = {
|
||||
forgotten: "memBorderForgotten",
|
||||
|
|
|
|||
|
|
@ -13,11 +13,20 @@ export { SpatialIndex } from "./canvas/hit-test"
|
|||
export { VersionChainIndex } from "./canvas/version-chain"
|
||||
|
||||
// Constants
|
||||
export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants"
|
||||
export {
|
||||
DEFAULT_COLORS,
|
||||
DEFAULT_HOVER_POPOVER_Z_INDEX,
|
||||
DEFAULT_LABELS,
|
||||
FORCE_CONFIG,
|
||||
GRAPH_SETTINGS,
|
||||
} from "./constants"
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MemoryGraphProps,
|
||||
MemoryGraphLabels,
|
||||
MemoryGraphLayering,
|
||||
ResolvedMemoryGraphLabels,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphThemeColors,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,27 @@ export interface GraphThemeColors {
|
|||
controlBorder: string
|
||||
}
|
||||
|
||||
export interface MemoryGraphLabels {
|
||||
documentGroup?: string
|
||||
documentTypeFallback?: string
|
||||
documentSourceEdge?: string
|
||||
documentToMemoryEdge?: string
|
||||
memoryCount?: (count: number) => string
|
||||
documentIdLabel?: string
|
||||
viewDocument?: string
|
||||
goToDocument?: string
|
||||
nextDocument?: string
|
||||
previousDocument?: string
|
||||
showMemory?: string
|
||||
loadingMoreDocuments?: (count: number) => string
|
||||
}
|
||||
|
||||
export type ResolvedMemoryGraphLabels = Required<MemoryGraphLabels>
|
||||
|
||||
export interface MemoryGraphLayering {
|
||||
hoverPopoverZIndex?: number
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
|
|
@ -195,6 +216,10 @@ export interface MemoryGraphProps {
|
|||
totalCount?: number
|
||||
/** Callback when user wants to view full document content */
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
/** Custom user-facing labels (partial) - merged with defaults */
|
||||
labels?: MemoryGraphLabels
|
||||
/** Overlay layering controls */
|
||||
layering?: MemoryGraphLayering
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
TOOL_DESCRIPTIONS,
|
||||
getContainerTags,
|
||||
} from "./tools-shared"
|
||||
import { forgetMemoryRequest } from "./shared/forget-memory"
|
||||
import type { SupermemoryToolsConfig } from "./types"
|
||||
|
||||
// Export individual tool creators
|
||||
|
|
@ -39,11 +40,11 @@ export const searchMemoriesTool = (
|
|||
.default(DEFAULT_VALUES.includeFullDocs)
|
||||
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
|
||||
limit: strict
|
||||
? z
|
||||
? z.coerce
|
||||
.number()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit)
|
||||
: z
|
||||
: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
|
|
@ -182,32 +183,30 @@ export const documentListTool = (
|
|||
.optional()
|
||||
.describe(PARAMETER_DESCRIPTIONS.containerTag),
|
||||
limit: strict
|
||||
? z
|
||||
? z.coerce
|
||||
.number()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit)
|
||||
: z
|
||||
: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.default(DEFAULT_VALUES.limit)
|
||||
.describe(PARAMETER_DESCRIPTIONS.limit),
|
||||
offset: z.number().optional().describe(PARAMETER_DESCRIPTIONS.offset),
|
||||
status: z.string().optional().describe(PARAMETER_DESCRIPTIONS.status),
|
||||
page: z.coerce.number().optional().describe(PARAMETER_DESCRIPTIONS.page),
|
||||
}),
|
||||
execute: async ({ containerTag, limit, offset, status }) => {
|
||||
execute: async ({ containerTag, limit, page }) => {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
|
||||
const response = await client.documents.list({
|
||||
containerTags: [tag],
|
||||
limit: limit || DEFAULT_VALUES.limit,
|
||||
...(offset !== undefined && { offset }),
|
||||
...(status && { status }),
|
||||
...(page !== undefined && { page }),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
documents: response.documents,
|
||||
documents: response.memories,
|
||||
pagination: response.pagination,
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -236,7 +235,7 @@ export const documentDeleteTool = (
|
|||
}),
|
||||
execute: async ({ documentId }) => {
|
||||
try {
|
||||
await client.documents.delete({ docId: documentId })
|
||||
await client.documents.delete(documentId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -303,11 +302,6 @@ export const memoryForgetTool = (
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) => {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
|
||||
const containerTags = getContainerTags(config)
|
||||
|
||||
return tool({
|
||||
|
|
@ -335,12 +329,16 @@ export const memoryForgetTool = (
|
|||
|
||||
const tag = containerTag || containerTags[0]
|
||||
|
||||
await client.memories.forget({
|
||||
containerTag: tag,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
})
|
||||
await forgetMemoryRequest(
|
||||
apiKey,
|
||||
{
|
||||
containerTag: tag as string,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
},
|
||||
config?.baseUrl,
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -96,7 +96,10 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
return await this.create(command.path, command.file_text)
|
||||
case "str_replace":
|
||||
if (!command.old_str || !command.new_str) {
|
||||
// new_str may legitimately be "" (deleting text), so only reject
|
||||
// when it is missing entirely. old_str must be non-empty — replacing
|
||||
// the empty string would prepend instead of replacing.
|
||||
if (!command.old_str || command.new_str === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: "old_str and new_str are required for str_replace command",
|
||||
|
|
@ -108,7 +111,11 @@ export class ClaudeMemoryTool {
|
|||
command.new_str,
|
||||
)
|
||||
case "insert":
|
||||
if (command.insert_line === undefined || !command.insert_text) {
|
||||
// insert_text may be "" (inserting a blank line).
|
||||
if (
|
||||
command.insert_line === undefined ||
|
||||
command.insert_text === undefined
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
|
|
@ -487,9 +494,9 @@ export class ClaudeMemoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
// Delete using the document ID
|
||||
// Note: We'll need to implement this based on supermemory's delete API
|
||||
// For now, we'll return a success message
|
||||
const documentId =
|
||||
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
|
||||
await this.client.documents.delete(documentId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -544,7 +551,14 @@ export class ClaudeMemoryTool {
|
|||
},
|
||||
})
|
||||
|
||||
// Delete the old document (would need proper delete API)
|
||||
// Remove the old document so the previous path stops showing up in
|
||||
// listings and search. Skip when both paths normalize to the same
|
||||
// customId — the add above already replaced the content.
|
||||
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
|
||||
if (oldNormalizedId !== newNormalizedId) {
|
||||
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
|
||||
await this.client.documents.delete(oldDocumentId)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TOOL_DESCRIPTIONS,
|
||||
getContainerTags,
|
||||
} from "../tools-shared"
|
||||
import { forgetMemoryRequest } from "../shared/forget-memory"
|
||||
import type { SupermemoryToolsConfig } from "../types"
|
||||
|
||||
/**
|
||||
|
|
@ -36,7 +37,7 @@ export interface ProfileResult {
|
|||
|
||||
export interface DocumentListResult {
|
||||
success: boolean
|
||||
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["documents"]
|
||||
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["memories"]
|
||||
pagination?: Awaited<
|
||||
ReturnType<Supermemory["documents"]["list"]>
|
||||
>["pagination"]
|
||||
|
|
@ -139,13 +140,9 @@ export const memoryToolSchemas = {
|
|||
description: PARAMETER_DESCRIPTIONS.limit,
|
||||
default: DEFAULT_VALUES.limit,
|
||||
},
|
||||
offset: {
|
||||
page: {
|
||||
type: "number",
|
||||
description: PARAMETER_DESCRIPTIONS.offset,
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
description: PARAMETER_DESCRIPTIONS.status,
|
||||
description: PARAMETER_DESCRIPTIONS.page,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
|
|
@ -359,13 +356,11 @@ export function createDocumentListFunction(
|
|||
return async function documentList({
|
||||
containerTag,
|
||||
limit,
|
||||
offset,
|
||||
status,
|
||||
page,
|
||||
}: {
|
||||
containerTag?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
status?: string
|
||||
page?: number
|
||||
}): Promise<DocumentListResult> {
|
||||
try {
|
||||
const tag = containerTag || containerTags[0]
|
||||
|
|
@ -373,13 +368,12 @@ export function createDocumentListFunction(
|
|||
const response = await client.documents.list({
|
||||
containerTags: [tag],
|
||||
limit: limit || DEFAULT_VALUES.limit,
|
||||
...(offset !== undefined && { offset }),
|
||||
...(status && { status }),
|
||||
...(page !== undefined && { page }),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
documents: response.documents,
|
||||
documents: response.memories,
|
||||
pagination: response.pagination,
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -470,7 +464,7 @@ export function createMemoryForgetFunction(
|
|||
apiKey: string,
|
||||
config?: SupermemoryToolsConfig,
|
||||
) {
|
||||
const { client, containerTags } = createClient(apiKey, config)
|
||||
const containerTags = getContainerTags(config)
|
||||
|
||||
return async function memoryForget({
|
||||
containerTag,
|
||||
|
|
@ -493,12 +487,16 @@ export function createMemoryForgetFunction(
|
|||
|
||||
const tag = containerTag || containerTags[0]
|
||||
|
||||
await client.memories.forget({
|
||||
containerTag: tag,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
})
|
||||
await forgetMemoryRequest(
|
||||
apiKey,
|
||||
{
|
||||
containerTag: tag as string,
|
||||
...(memoryId && { id: memoryId }),
|
||||
...(memoryContent && { content: memoryContent }),
|
||||
...(reason && { reason }),
|
||||
},
|
||||
config?.baseUrl,
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
38
packages/tools/src/shared/forget-memory.ts
Normal file
38
packages/tools/src/shared/forget-memory.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
export interface ForgetMemoryParams {
|
||||
containerTag: string
|
||||
id?: string
|
||||
content?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a memory as forgotten via `DELETE /v4/memories`.
|
||||
*
|
||||
* The supermemory SDK version this package depends on (v3) has no
|
||||
* `memories.forget` method, so the endpoint is called directly — the same
|
||||
* pattern the middleware already uses for `/v4/profile` and
|
||||
* `/v4/conversations`.
|
||||
*/
|
||||
export async function forgetMemoryRequest(
|
||||
apiKey: string,
|
||||
params: ForgetMemoryParams,
|
||||
baseUrl: string = DEFAULT_BASE_URL,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/v4/memories`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error")
|
||||
throw new Error(
|
||||
`Supermemory forget memory failed: ${response.status} ${response.statusText}. ${errorText}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue