mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
Merge branch 'main' into fix/1030-listmemories-mcp-tool
This commit is contained in:
commit
c47529c378
138 changed files with 17137 additions and 4780 deletions
49
README.md
49
README.md
|
|
@ -13,6 +13,7 @@
|
|||
<p align="center">
|
||||
<a href="https://supermemory.ai/docs">Docs</a> ·
|
||||
<a href="https://supermemory.ai/docs/quickstart">Quickstart</a> ·
|
||||
<a href="https://supermemory.ai/docs/self-hosting/overview">Self-host</a> ·
|
||||
<a href="https://console.supermemory.ai">Dashboard</a> ·
|
||||
<a href="https://supermemory.link/discord">Discord</a>
|
||||
</p>
|
||||
|
|
@ -77,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies.
|
|||
|
||||
**[→ Jump to developer quickstart](#build-with-supermemory-api)**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
<h3>🖥️ I want to run it myself</h3>
|
||||
|
||||
State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
|
||||
**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -301,6 +317,38 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs)
|
|||
|
||||
---
|
||||
|
||||
## Supermemory local — run it yourself
|
||||
|
||||
State-of-the-art memory, on your machine. One binary. Zero config.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
# or
|
||||
npx supermemory local
|
||||
```
|
||||
|
||||
```bash
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`.
|
||||
|
||||
```typescript
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...",
|
||||
baseURL: "http://localhost:6767", // that's the only change
|
||||
});
|
||||
```
|
||||
|
||||
- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Supermemory is state of the art across all major AI memory benchmarks:
|
||||
|
|
@ -354,6 +402,7 @@ Your app / AI tool
|
|||
|
||||
- 📖 [Documentation](https://supermemory.ai/docs)
|
||||
- 🚀 [Quickstart](https://supermemory.ai/docs/quickstart)
|
||||
- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview)
|
||||
- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview)
|
||||
- 🔌 [Integrations](https://supermemory.ai/docs/integrations)
|
||||
- 💬 [Discord](https://supermemory.link/discord)
|
||||
|
|
|
|||
176
apps/docs/concepts/container-tags.mdx
Normal file
176
apps/docs/concepts/container-tags.mdx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
title: "Container Tags"
|
||||
sidebarTitle: "Container Tags"
|
||||
description: "The isolation boundary that groups and partitions memories by user, project, or any logical scope"
|
||||
icon: "folder"
|
||||
---
|
||||
|
||||
A **container tag** is the primary way you organize and isolate memories in Supermemory. It's a simple string identifier you attach to content when you add it — and that you pass back when you search, list, or update it.
|
||||
|
||||
Think of a container tag as a **namespace**: every memory tagged with `user_alex` lives in its own isolated space, completely separate from memories tagged `user_jordan`. This is what makes Supermemory safe to use in multi-tenant applications — one user can never see another user's memories unless you explicitly query across both tags.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Group" icon="layers">
|
||||
Bucket memories by user, project, agent, workspace, or any boundary that makes sense for your app.
|
||||
</Card>
|
||||
<Card title="Isolate" icon="shield">
|
||||
Each container tag maps to its own vector namespace, so search and retrieval never leak across boundaries.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
When you add a memory with a container tag, Supermemory automatically creates a **space** for that tag (scoped to your organization) if one doesn't already exist. You don't need to provision anything ahead of time — the first write with a new tag creates the container, and subsequent writes reuse it.
|
||||
|
||||
```typescript
|
||||
// First call auto-creates the "user_alex" container
|
||||
await client.add({
|
||||
content: "Alex prefers dark mode and concise answers",
|
||||
containerTag: "user_alex",
|
||||
});
|
||||
|
||||
// Later, retrieve only Alex's memories
|
||||
const results = await client.search.memories({
|
||||
q: "what are the user's UI preferences?",
|
||||
containerTag: "user_alex",
|
||||
});
|
||||
```
|
||||
|
||||
Under the hood, each container tag is hashed into a dedicated vector namespace. Embeddings, chunks, and memory entries for one tag are stored and searched independently of every other tag — there is no shared index to filter through, which is why isolation is strict rather than best-effort.
|
||||
|
||||
<Note>
|
||||
A container tag is an **opaque identifier you choose**. Supermemory does not parse meaning out of it — `user_123`, `project_mobile`, and `org:acme:team:growth` are all equally valid. Pick a convention that mirrors the access boundaries in your own application.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Naming rules
|
||||
|
||||
Container tags are validated on every request. A tag must:
|
||||
|
||||
- Be **100 characters or less**
|
||||
- Contain only **alphanumeric characters, hyphens (`-`), underscores (`_`), and colons (`:`)**
|
||||
|
||||
Matching pattern: `^[a-zA-Z0-9_:-]+$`
|
||||
|
||||
```typescript
|
||||
// ✅ Valid
|
||||
"user_123"
|
||||
"project-mobile-app"
|
||||
"org:acme:user:john"
|
||||
"tenant_42_workspace_7"
|
||||
|
||||
// ❌ Invalid — spaces, slashes, and other symbols are rejected
|
||||
"user 123"
|
||||
"project/mobile"
|
||||
"team@acme"
|
||||
```
|
||||
|
||||
The colon is intentionally allowed so you can build **hierarchical** tags (for example `org:acme:user:john`) that encode several levels of structure in a single identifier.
|
||||
|
||||
---
|
||||
|
||||
## `containerTag` vs `containerTags`
|
||||
|
||||
Supermemory's current API uses a **single** `containerTag` string per request.
|
||||
|
||||
<Warning>
|
||||
The plural `containerTags` array field is **deprecated**. It still works for backward compatibility on older (`/v3`) endpoints, but new integrations should use the singular `containerTag` string. The `/v4` API only accepts `containerTag`.
|
||||
</Warning>
|
||||
|
||||
| API field | Type | Status |
|
||||
|-----------|------|--------|
|
||||
| `containerTag` | `string` | ✅ Current — use this |
|
||||
| `containerTags` | `string[]` | ⚠️ Deprecated |
|
||||
|
||||
---
|
||||
|
||||
## Where container tags are used
|
||||
|
||||
The same tag flows through the entire lifecycle of a memory. Pass it consistently and your data stays neatly partitioned.
|
||||
|
||||
| Operation | Behavior |
|
||||
|-----------|----------|
|
||||
| **Add** | Writes the memory into the tag's container (auto-creating the space). |
|
||||
| **Search** | Restricts retrieval to the given tag's namespace. |
|
||||
| **List** | Returns only memories belonging to the tag(s). |
|
||||
| **Update / Delete** | Targets the memory inside the specified tag's container. |
|
||||
|
||||
```typescript
|
||||
// Add
|
||||
await client.add({ content: "Q1 planning notes", containerTag: "project_q1" });
|
||||
|
||||
// Search within the same container
|
||||
await client.search.memories({ q: "planning", containerTag: "project_q1" });
|
||||
|
||||
// List everything in the container
|
||||
await client.documents.list({ containerTags: ["project_q1"] });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access control
|
||||
|
||||
Container tags are also an **authorization boundary**, not just an organizational one. Two mechanisms can restrict which tags a given caller may touch:
|
||||
|
||||
- **API key scopes** — an API key can be limited to a specific set of container tags, with read or write permission per tag.
|
||||
- **Member restrictions** — an organization member can be granted access to only certain container tags.
|
||||
|
||||
When a request is restricted, Supermemory validates the requested tag against the caller's allowed set:
|
||||
|
||||
- Requesting a tag outside the allowed set returns `403 Forbidden`.
|
||||
- A write (add/update/delete) to a read-only tag returns `403 Forbidden`.
|
||||
- If no tag is supplied by a restricted caller, the request is automatically scoped to their allowed tag(s).
|
||||
|
||||
This means you can hand out an API key that is physically incapable of reading or writing another tenant's data, enforced at the data layer rather than in your application code.
|
||||
|
||||
---
|
||||
|
||||
## Per-container settings
|
||||
|
||||
Each container tag can carry its own configuration, independent of other tags in the same organization:
|
||||
|
||||
| Setting | Purpose |
|
||||
|---------|---------|
|
||||
| `name` | A human-friendly display name for the container. |
|
||||
| `entityContext` | A custom context prompt applied when processing documents in this container — useful for steering extraction and summarization per project or tenant. |
|
||||
|
||||
```typescript
|
||||
await client.containerTags.update("project_research", {
|
||||
entityContext: "This project contains research papers about machine learning.",
|
||||
});
|
||||
```
|
||||
|
||||
Container tags can also be **merged** when you need to consolidate two buckets of memories into one.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a convention
|
||||
|
||||
Pick a tagging scheme that maps onto the isolation boundaries your application actually needs.
|
||||
|
||||
| Pattern | Example | Use case |
|
||||
|---------|---------|----------|
|
||||
| User isolation | `user_{userId}` | Per-user memory in a consumer app |
|
||||
| Project grouping | `project_{projectId}` | Project- or workspace-scoped content |
|
||||
| Agent scoping | `agent_{agentId}` | Separate long-term memory per AI agent |
|
||||
| Hierarchical | `org:{orgId}:user:{userId}` | Multi-level, multi-tenant SaaS |
|
||||
|
||||
<Tip>
|
||||
Keep tags **deterministic** — derive them directly from IDs you already have (a user ID, a tenant ID) so you can always reconstruct the right tag at query time without a lookup.
|
||||
</Tip>
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Organizing & Filtering" icon="filter" href="/concepts/filtering">
|
||||
Combine container tags with metadata filters for precise retrieval.
|
||||
</Card>
|
||||
<Card title="Adding Memories" icon="plus" href="/add-memories">
|
||||
See container tags in action across the add API.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
---
|
||||
title: 'Self Hosting'
|
||||
description: 'Deploy your own instance of the supermemory API on Cloudflare Workers'
|
||||
---
|
||||
|
||||
<Warning>
|
||||
This guide is intended for **enterprise customers only** who have specifically opted for self-hosting as part of their enterprise plan. If you're on a standard plan, please use our hosted API at [console.supermemory.ai](https://console.supermemory.ai).
|
||||
</Warning>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, you'll need to gather several API keys and set up accounts with various services. This comprehensive guide will walk you through obtaining each required component.
|
||||
|
||||
### Enterprise Deployment Package
|
||||
|
||||
Your enterprise deployment package is provided by the supermemory team and contains:
|
||||
- Your unique Host ID (`NEXT_PUBLIC_HOST_ID`)
|
||||
- The compiled JavaScript bundle
|
||||
- The deployment script
|
||||
|
||||
Contact your supermemory enterprise representative to receive your deployment package.
|
||||
|
||||
### Cloudflare
|
||||
|
||||
|
||||
#### Create Account
|
||||
|
||||
1. Go to [cloudflare.com](https://dash.cloudflare.com/sign-up) and create an account
|
||||
3. Your **Account ID** is the long randon string in the URL bar
|
||||
|
||||
#### Create API Token
|
||||
|
||||
1. Navigate to [Cloudflare API Tokens](https://dash.cloudflare.com/?to=/:account/api-tokens)
|
||||
2. Click **"Create Token"**
|
||||
3. Use the **"Custom token"** template
|
||||
4. Configure the token with these permissions:
|
||||
- **Account:AI Gateway:Edit**
|
||||
- **Account:Hyperdrive:Edit**
|
||||
- **Account:Workers KV Storage:Edit**
|
||||
- **Account:Workers R2 Storage:Edit**
|
||||
7. Click **"Continue to summary"** → **"Create Token"**
|
||||
8. **Important**: Copy and securely store the token immediately (it won't be shown again)
|
||||
|
||||
#### Enable Workers
|
||||
|
||||
1. In your Cloudflare dashboard, go to **Workers & Pages**
|
||||
2. If prompted, accept the Workers terms of service
|
||||
3. Choose a subdomain for your workers (e.g., `yourcompany.workers.dev`)
|
||||
|
||||
Your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are now ready.
|
||||
|
||||
### Database
|
||||
|
||||
You'll need to provide a PostgreSQL connection string via the `DATABASE_URL` environment variable.
|
||||
|
||||
The database must:
|
||||
- Support the **pgvector extension** for vector operations
|
||||
- Be accessible from Cloudflare Workers
|
||||
- Support SSL connections
|
||||
- Allow connections from Cloudflare's IP ranges
|
||||
|
||||
Your connection string should follow this format:
|
||||
```
|
||||
postgresql://username:password@hostname:port/database
|
||||
```
|
||||
|
||||
### LLM Providers
|
||||
|
||||
#### OpenAI
|
||||
|
||||
1. Go to [platform.openai.com](https://platform.openai.com)
|
||||
2. Sign in or create an account
|
||||
3. Navigate to **API Keys** in the left sidebar
|
||||
4. Click **"Create new secret key"**
|
||||
5. Name your key (e.g., "supermemory Self-Hosted")
|
||||
6. Copy the key and store it securely
|
||||
7. Add billing information if you haven't already
|
||||
|
||||
#### Anthropic
|
||||
|
||||
1. Go to [console.anthropic.com](https://console.anthropic.com)
|
||||
2. Create an account and complete verification
|
||||
3. Navigate to **API Keys**
|
||||
4. Click **"Create Key"**
|
||||
5. Name your key and copy it securely
|
||||
|
||||
#### Gemini
|
||||
|
||||
1. Go to [Google AI Studio](https://aistudio.google.com)
|
||||
2. Sign in with your Google account
|
||||
3. Click **"Get API key"** → **"Create API key"**
|
||||
4. Choose an existing Google Cloud project or create a new one
|
||||
5. Copy your API key
|
||||
|
||||
#### Groq
|
||||
|
||||
1. Go to [console.groq.com](https://console.groq.com)
|
||||
2. Sign up for an account
|
||||
3. Navigate to **API Keys**
|
||||
4. Click **"Create API Key"**
|
||||
5. Name your key and copy it
|
||||
|
||||
|
||||
|
||||
{/* TODO: Add OAuth documentation */}
|
||||
{/* ### Authentication Providers
|
||||
#### GitHub OAuth (Optional)
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click **"New OAuth App"**
|
||||
3. Fill in the application details:
|
||||
- **Application name**: Your app name
|
||||
- **Homepage URL**: Your API domain (e.g., `https://api.yourdomain.com`)
|
||||
- **Authorization callback URL**: `https://api.yourdomain.com/api/auth/callback/github`
|
||||
4. Click **"Register application"**
|
||||
5. Note the **Client ID** and generate a **Client Secret**
|
||||
6. Use these for `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`
|
||||
|
||||
#### Google OAuth (Optional)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the **Google+ API**
|
||||
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
|
||||
5. Choose **Web application**
|
||||
6. Add your domain to **Authorized JavaScript origins**
|
||||
7. Add `https://api.yourdomain.com/api/auth/callback/google` to **Authorized redirect URIs**
|
||||
8. Copy the **Client ID** and **Client secret**
|
||||
9. Use these for `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` */}
|
||||
|
||||
### Email Service Setup
|
||||
|
||||
#### Resend
|
||||
|
||||
1. Go to [resend.com](https://resend.com) and create an account
|
||||
2. Navigate to **API Keys**
|
||||
3. Click **"Create API Key"**
|
||||
4. Name your key (e.g., "supermemory Production")
|
||||
5. Copy the key for `RESEND_API_KEY`
|
||||
6. Verify your sending domain in the **Domains** section
|
||||
|
||||
### Connectors (Optional)
|
||||
|
||||
#### Google Drive
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||
2. Create or select a project
|
||||
3. Enable the **Google Drive API**
|
||||
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
|
||||
5. Configure the OAuth consent screen if required
|
||||
6. Choose **Web application**
|
||||
7. Add authorized redirect URIs for your domain
|
||||
8. Copy `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
||||
|
||||
#### Microsoft OneDrive
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com)
|
||||
2. Navigate to **Microsoft Entra ID** → **App registrations**
|
||||
3. Click **"New registration"**
|
||||
4. Name your app and set redirect URI
|
||||
5. Go to **Certificates & secrets** → **New client secret**
|
||||
6. Copy the **Application (client) ID** and **Client secret**
|
||||
7. Use for `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET`
|
||||
|
||||
#### Notion
|
||||
|
||||
1. Go to [Notion Developers](https://developers.notion.com)
|
||||
2. Click **"Create new integration"**
|
||||
3. Fill in the integration details
|
||||
4. Copy the **Internal Integration Token**
|
||||
5. Set up OAuth if needed for user connections
|
||||
6. Use for `NOTION_CLIENT_ID` and `NOTION_CLIENT_SECRET`
|
||||
|
||||
---
|
||||
|
||||
## Setup deployment files
|
||||
|
||||
Extract the deployment package provided by the supermemory team to your preferred directory:
|
||||
|
||||
```bash
|
||||
# Extract the deployment package
|
||||
$ unzip supermemory-enterprise-deployment.zip
|
||||
$ cd supermemory-deployment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configure environment variables
|
||||
|
||||
The deployment script reads **all** environment variables from your shell at runtime. We ship an example file that lists the full set supported by the worker.
|
||||
|
||||
```bash
|
||||
# Copy the template and start editing
|
||||
$ cp packages/alchemy/env.example .env
|
||||
|
||||
# Open the file in your editor of choice and fill in the blanks
|
||||
$ $EDITOR .env
|
||||
```
|
||||
|
||||
Below is a quick reference.
|
||||
**Required** values are mandatory for a successful deploy – leave optional ones empty if you don't need the related feature.
|
||||
|
||||
| Name | Required? | Description |
|
||||
|------|-----------|-------------|
|
||||
| `NODE_ENV` | ✅ | `development`, `staging` or `production`. |
|
||||
| `NEXT_PUBLIC_HOST_ID` | ✅ | Your unique Host ID provided by the supermemory team. |
|
||||
| `BETTER_AUTH_SECRET` | ✅ | Random 32-byte string – run `openssl rand -base64 32`. |
|
||||
| `BETTER_AUTH_URL` | ✅ | Public base URL for the API (no trailing `/`). Example: `https://api.example.com`. |
|
||||
| `DATABASE_URL` | ✅ | Postgres connection string (e.g. `postgres://user:pass@host:5432/db`). |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Your Cloudflare account ID. |
|
||||
| `CLOUDFLARE_API_TOKEN` | ✅ | Token created in *Prerequisites*. |
|
||||
| `OPENAI_API_KEY` | ✅ | Key from [platform.openai.com](https://platform.openai.com). |
|
||||
| `RESEND_API_KEY` | ✅ | E-mail provider key if you plan to send e-mails. |
|
||||
| `ANTHROPIC_API_KEY` | | Needed to use Claude models. |
|
||||
| `GEMINI_API_KEY` | | Key for Google Gemini models. |
|
||||
| `GROQ_API_KEY` | | Key for Groq models. |
|
||||
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | | Enable GitHub OAuth login. |
|
||||
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | | Enable Google OAuth login. |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | | Needed for Google Drive connector. |
|
||||
| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | | Needed for OneDrive connector. |
|
||||
| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | | Needed for Notion connector. |
|
||||
| `CLOUDFLARE_AI_GATEWAY_NAME` / `CLOUDFLARE_AI_GATEWAY_TOKEN` | | Only if you want to route requests through an AI Gateway. |
|
||||
| `SENTRY_DSN` | | If you use Sentry for error reporting. |
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
With your `.env` in place, run the deployment script:
|
||||
|
||||
```bash
|
||||
# Run the deployment script provided in your package
|
||||
$ bun ./deploy.ts
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Updating Your Deployment
|
||||
|
||||
To update your supermemory deployment, follow the same process as the initial deployment described in the **Deploy** section above. You can reuse your existing `.env` file and add/remove any new environment variables as needed.
|
||||
|
||||
---
|
||||
|
|
@ -71,6 +71,15 @@
|
|||
"group": "Getting Started",
|
||||
"pages": ["intro", "quickstart", "vibe-coding"]
|
||||
},
|
||||
{
|
||||
"group": "Self-Hosting",
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
"self-hosting/quickstart",
|
||||
"self-hosting/configuration",
|
||||
"self-hosting/local-vs-enterprise"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Concepts",
|
||||
"pages": [
|
||||
|
|
@ -79,6 +88,7 @@
|
|||
"concepts/content-types",
|
||||
"concepts/super-rag",
|
||||
"concepts/memory-vs-rag",
|
||||
"concepts/container-tags",
|
||||
"concepts/filtering",
|
||||
"concepts/user-profiles",
|
||||
"concepts/customization",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
|
|||
|
||||
[Claude-Supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ icon: "terminal"
|
|||
- **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session.
|
||||
- **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` (or set `baseUrl` in `~/.codex/supermemory.json`) and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
|
|||
|
||||
[OpenClaw](https://github.com/supermemoryai/openclaw-supermemory) is a multi-platform AI messaging gateway that connects to WhatsApp, Telegram, Discord, Slack, iMessage, and other messaging channels. The Supermemory plugin gives OpenClaw memory across every channel.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_BASE_URL="http://localhost:6767"` (or set `baseUrl` in the plugin config) and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Install the Plugin
|
||||
|
||||
Get started by installing the plugin with a single command.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
|
|||
|
||||
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
|
||||
|
||||
<Tip>
|
||||
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
|
||||
</Tip>
|
||||
|
||||
## Get Your API Key
|
||||
|
||||
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ icon: "/images/supermemory.svg"
|
|||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Tip>
|
||||
Both SDKs also work against [self-hosted Supermemory](/self-hosting/overview) — pass `baseURL: "http://localhost:6767"` (TypeScript) or `base_url="http://localhost:6767"` (Python) when creating the client.
|
||||
</Tip>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
## Installation
|
||||
|
|
|
|||
|
|
@ -82,5 +82,8 @@ All three approaches share the **same context pool** when using the same user ID
|
|||
<Card title="How it Works" icon="cpu" href="/concepts/how-it-works">
|
||||
Understand the knowledge graph architecture
|
||||
</Card>
|
||||
<Card title="Self-host it" icon="server" href="/self-hosting/overview">
|
||||
Run Supermemory on your own machine — one binary, zero config, fully offline
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ icon: "play"
|
|||
**Using Vercel AI SDK?** Check out the [AI SDK integration](/integrations/ai-sdk) for the cleanest implementation with `@supermemory/tools/ai-sdk`.
|
||||
</Tip>
|
||||
|
||||
<Tip>
|
||||
**Prefer to run it locally?** Supermemory is also a [self-hostable single binary](/self-hosting/overview) — `curl -fsSL https://supermemory.ai/install | bash` and you're running.
|
||||
</Tip>
|
||||
|
||||
## Memory API
|
||||
|
||||
**Step 1.** Sign up for [Supermemory's Developer Platform](http://console.supermemory.ai) to get the API key. Click on **API Keys -> Create API Key** to generate one.
|
||||
|
|
|
|||
135
apps/docs/self-hosting/configuration.mdx
Normal file
135
apps/docs/self-hosting/configuration.mdx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
title: "Self-Hosting Configuration"
|
||||
sidebarTitle: "Configuration"
|
||||
description: "Every environment variable the self-hosted server understands."
|
||||
icon: "settings"
|
||||
---
|
||||
|
||||
The self-hosted server aims for **zero configuration** — the only 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 installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager.
|
||||
|
||||
## Core
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PORT` (or `SUPERMEMORY_PORT`) | HTTP listen port | `6767` |
|
||||
| `SUPERMEMORY_DATA_DIR` | Where the graph engine's data, auth secret, and model cache live | `./.supermemory` |
|
||||
|
||||
## LLM providers
|
||||
|
||||
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**:
|
||||
|
||||
| Variable | Provider |
|
||||
|---|---|
|
||||
| `OPENAI_API_KEY` | OpenAI — or any OpenAI-compatible endpoint, see below |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic |
|
||||
| `GEMINI_API_KEY` | Google AI Studio (Gemini) |
|
||||
| `GROQ_API_KEY` | Groq |
|
||||
| `WORKERS_AI_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Workers AI |
|
||||
| `GOOGLE_VERTEX_PROJECT_ID` + `GOOGLE_VERTEX_LOCATION` | GCP Vertex AI |
|
||||
|
||||
<Tip>
|
||||
No key set? The server walks you through it. On first boot, an interactive setup wizard asks which provider you want, securely prompts for the key, and saves it encrypted — including a custom base URL and model name if you pick an OpenAI-compatible endpoint.
|
||||
</Tip>
|
||||
|
||||
With multiple providers configured, the first one in the order above is used.
|
||||
|
||||
<Note>
|
||||
Image, video, and high-fidelity PDF understanding require a Gemini or Vertex AI key. Text ingestion, memory extraction, and search work with any provider.
|
||||
</Note>
|
||||
|
||||
### Fully offline with local models
|
||||
|
||||
`OPENAI_API_KEY` + `OPENAI_BASE_URL` covers any OpenAI-compatible endpoint: Ollama, LM Studio, vLLM, llama.cpp server, Together, Fireworks, and more.
|
||||
|
||||
```bash
|
||||
# Ollama example — gpt-oss-20b works great
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1
|
||||
OPENAI_API_KEY=ollama # any non-empty string for local runners
|
||||
OPENAI_MODEL=gpt-oss:20b
|
||||
```
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `OPENAI_BASE_URL` | OpenAI-compatible endpoint URL | OpenAI |
|
||||
| `OPENAI_MODEL` | Model ID sent to that endpoint | `gpt-5.1` |
|
||||
| `OPENAI_FAST_MODEL` | Override for fast/light tasks | `OPENAI_MODEL` |
|
||||
| `OPENAI_TEXT_MODEL` | Override for heavier text tasks | `OPENAI_MODEL` |
|
||||
|
||||
## File storage
|
||||
|
||||
Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
|
||||
|
||||
## Embedding performance
|
||||
|
||||
Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
|
||||
| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
|
||||
|
||||
## Memory limits & ingestion queue
|
||||
|
||||
The server manages memory for you and separates the two kinds of work you send it:
|
||||
|
||||
- **Searches are always served immediately.** They never wait behind ingestion, regardless of how much is queued.
|
||||
- **Adds are accepted instantly but processed through a queue.** A `POST /v3/documents` call returns in milliseconds with status `queued`; extraction, embedding, and indexing happen in the background at a controlled pace.
|
||||
|
||||
Ingestion may grow the server's memory usage by at most `SUPERMEMORY_EMBEDDING_RAM_LIMIT` (default **1 GB**) above its post-boot baseline. Past that, new documents simply wait in the queue until memory drops back under the limit — nothing is dropped, ingestion just slows down. The limit is measured above the boot baseline because the built-in local embeddings and storage engine have a fixed footprint that exists before any document is processed.
|
||||
|
||||
The limit is printed at boot, and whenever adds are waiting the binary shows a live status line in the terminal:
|
||||
|
||||
```
|
||||
[ingest] memory limit 1.0 GB above baseline (1.6 GB) · 2 concurrent — set SUPERMEMORY_EMBEDDING_RAM_LIMIT=ngb to change
|
||||
[ingest] 2 running · 193 queued · 0.4 GB / 1.0 GB ingest memory
|
||||
[ingest] 2 running · 193 queued · paused — 1.1 GB / 1.0 GB ingest memory, waiting for it to drop
|
||||
[ingest] resumed — memory back under the 1.0 GB ingest limit
|
||||
```
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_RAM_LIMIT` | Memory ingestion may use above the boot baseline. Accepts `1gb`, `1.5gb`, `512mb`, or a bare number (GB). | `1gb` |
|
||||
| `SUPERMEMORY_INGEST_CONCURRENCY` | Documents processed concurrently | `2` |
|
||||
|
||||
```bash
|
||||
# Give ingestion 4 GB of headroom on a larger machine
|
||||
SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server
|
||||
```
|
||||
|
||||
Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly.
|
||||
|
||||
## Telemetry
|
||||
|
||||
The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_DISABLE_TELEMETRY` | Set to `1` to also disable internal AI SDK telemetry instrumentation | unset |
|
||||
|
||||
## Platform-only features
|
||||
|
||||
These exist in the codebase but are exclusive to the [hosted platform](https://console.supermemory.ai) — the self-hosted binary doesn't include them:
|
||||
|
||||
- **Connectors** — Google Drive, Notion, Gmail, OneDrive background sync
|
||||
- **Supermemory MCP** — managed MCP server endpoints
|
||||
- **Optimized memory extraction** — the platform's extraction pipeline is tuned for higher quality at lower cost than bring-your-own-key
|
||||
- **Managed scale** — globally distributed infrastructure, no capacity planning
|
||||
|
||||
Any other environment variables you may find referenced in the codebase are platform-only: the self-hosted binary ignores them even when set.
|
||||
|
||||
## Example: production-ish `.env`
|
||||
|
||||
```dotenv
|
||||
# Persistent data location
|
||||
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
|
||||
|
||||
# One LLM provider
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
That's enough for full ingestion, memory extraction, and hybrid search.
|
||||
56
apps/docs/self-hosting/local-vs-enterprise.mdx
Normal file
56
apps/docs/self-hosting/local-vs-enterprise.mdx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: "Local vs. Enterprise"
|
||||
sidebarTitle: "Local vs. Enterprise"
|
||||
description: "Supermemory local is for builders. Supermemory Enterprise is for organizations."
|
||||
icon: "building-2"
|
||||
---
|
||||
|
||||
Supermemory local — the self-hosted binary — is free, open source, and built for individual developers: local-first workflows, prototyping, air-gapped experiments, privacy-sensitive side projects.
|
||||
|
||||
**Supermemory Enterprise** is the full platform, run for your organization: the same memory engine with proprietary models, organizational controls, and infrastructure that scales with you — without you operating any of it.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Supermemory local | Enterprise |
|
||||
|---|---|---|
|
||||
| **Memory engine** | Full graph engine, embedded | Full graph engine, managed |
|
||||
| **Models** | Bring your own key (any provider, incl. fully offline) | Proprietary models tuned for long-horizon data understanding |
|
||||
| **Authentication** | Single auto-generated API key | Organization-wide authentication and access controls |
|
||||
| **Team access** | Single org on one machine | Multi-member organizations, roles, and scoped API keys |
|
||||
| **Observability** | Server logs | Control dashboard: usage analytics, ingestion monitoring, request logs |
|
||||
| **Control** | Env vars on your box | Org-wide settings, key management, and governance from the console |
|
||||
| **Connectors** | — | Google Drive, Notion, Gmail, OneDrive with continuous background sync |
|
||||
| **Scalability** | One machine, one process | Globally distributed, scales elastically with your workload |
|
||||
| **Hosting** | You run it | Fully managed — or dedicated deployments for compliance needs |
|
||||
| **Support** | Community ([GitHub](https://git.new/memory)) | Dedicated support, onboarding, and SLAs |
|
||||
|
||||
## What Enterprise adds
|
||||
|
||||
### Auth and team access
|
||||
|
||||
Local runs as a single-tenant server with one API key. Enterprise gives your whole organization structured access: member roles, and API keys scoped per environment, per team, or per app — all revocable from one place.
|
||||
|
||||
### Observability and the control dashboard
|
||||
|
||||
Local gives you logs. Enterprise gives you the console: live usage analytics, ingestion pipeline visibility, search and request logs, and per-key attribution — so you always know what your agents are remembering, and what it costs.
|
||||
|
||||
### Memory quality
|
||||
|
||||
Local runs the extraction pipeline on whatever model you bring. Enterprise runs it on Supermemory's proprietary models, purpose-tuned for long-horizon data understanding — higher-quality memories at a lower effective cost than any bring-your-own-key setup.
|
||||
|
||||
### Scale and hosting
|
||||
|
||||
Local is bounded by one machine — which is the point. Enterprise runs on globally distributed infrastructure that scales with your ingestion volume and query load, with no capacity planning on your side. For strict data residency or compliance requirements, dedicated deployment options are available.
|
||||
|
||||
## Moving between them
|
||||
|
||||
The two speak the same API. Code written against your local server moves to Enterprise by changing the `baseURL` — and vice versa. Prototype locally, ship on Enterprise.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Talk to us" icon="mail" href="mailto:dhravya@supermemory.com">
|
||||
Get a walkthrough of Supermemory Enterprise for your team
|
||||
</Card>
|
||||
<Card title="Start locally" icon="terminal" href="/self-hosting/quickstart">
|
||||
Install the binary and build against the same API today
|
||||
</Card>
|
||||
</CardGroup>
|
||||
85
apps/docs/self-hosting/overview.mdx
Normal file
85
apps/docs/self-hosting/overview.mdx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
title: "Self-Hosting Supermemory"
|
||||
sidebarTitle: "Overview"
|
||||
description: "State-of-the-art memory, running on your machine. One binary, zero config."
|
||||
icon: "server"
|
||||
---
|
||||
|
||||
Supermemory runs on your own hardware. It's the same memory engine behind the [hosted platform](https://console.supermemory.ai) — ingestion, memory extraction, hybrid semantic search, and the full API — as a single self-contained binary.
|
||||
|
||||
<CodeGroup>
|
||||
```bash curl
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
|
||||
```bash npx
|
||||
npx supermemory local
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
No Docker. No database to provision. No config files. It boots in seconds with everything built in, and it's [open source](https://git.new/memory).
|
||||
|
||||
## Zero config, actually
|
||||
|
||||
Run the binary with nothing set and you get a complete memory system:
|
||||
|
||||
- **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings.
|
||||
- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded.
|
||||
- **An API key, generated for you** — printed on first boot, ready to paste into any SDK.
|
||||
- **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works.
|
||||
|
||||
The only thing you bring is a model. In production, Supermemory runs its own proprietary models, purpose-tuned for long-horizon data understanding and memory extraction. Self-hosted, the same pipeline runs on whatever model you point it at — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. Bring a key and go. Or don't bring one at all:
|
||||
|
||||
## Runs fully offline
|
||||
|
||||
Supermemory works with any OpenAI-compatible endpoint, which means it runs end-to-end on your machine with a local model — Ollama, LM Studio, vLLM, llama.cpp. `gpt-oss-20b` is a great fit:
|
||||
|
||||
```bash
|
||||
OPENAI_BASE_URL=http://localhost:11434/v1 \
|
||||
OPENAI_API_KEY=ollama \
|
||||
OPENAI_MODEL=gpt-oss:20b \
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
Local graph engine, local embeddings, local LLM. Your data never leaves the building.
|
||||
|
||||
## Drop-in with your existing code
|
||||
|
||||
The self-hosted server speaks the same API as the hosted platform. Point any Supermemory SDK at it with a one-line change:
|
||||
|
||||
```typescript
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...", // printed on first boot
|
||||
baseURL: "http://localhost:6767",
|
||||
})
|
||||
```
|
||||
|
||||
Everything in the [Memory API docs](/quickstart) works the same way. The coding plugins do too — [Claude Code](/integrations/claude-code), [Codex](/integrations/codex), and [OpenCode](/integrations/opencode) all target your local server with `SUPERMEMORY_API_URL=http://localhost:6767`.
|
||||
|
||||
## Self-hosted vs. the platform
|
||||
|
||||
Self-hosted is free, open source, and great for local development, air-gapped environments, and privacy-sensitive workloads. The hosted platform is where the full product lives:
|
||||
|
||||
| | Self-hosted | Platform |
|
||||
|---|---|---|
|
||||
| Full Memory API | ✅ | ✅ |
|
||||
| Hybrid semantic search | ✅ | ✅ |
|
||||
| Local embeddings | ✅ | Managed |
|
||||
| File ingestion (PDFs, images) | ✅ | ✅ |
|
||||
| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
|
||||
| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
|
||||
| Memory extraction | Your model, your key | Proprietary long-horizon models — higher quality, cheaper at scale |
|
||||
| Infrastructure | Your machine | Globally distributed, scales with you |
|
||||
|
||||
If you outgrow a single machine — or want connectors, MCP, and the best-tuned extraction pipeline — [the platform](https://console.supermemory.ai) is one `baseURL` change away. Running this for a team or organization? See [Local vs. Enterprise](/self-hosting/local-vs-enterprise).
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<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>
|
||||
</CardGroup>
|
||||
151
apps/docs/self-hosting/quickstart.mdx
Normal file
151
apps/docs/self-hosting/quickstart.mdx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
title: "Self-Hosting Quickstart"
|
||||
sidebarTitle: "Quickstart"
|
||||
description: "From zero to your first memory in under two minutes."
|
||||
icon: "play"
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
<Tabs>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="npx">
|
||||
```bash
|
||||
npx supermemory local
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="bunx">
|
||||
```bash
|
||||
bunx supermemory local
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
supermemory-server
|
||||
```
|
||||
|
||||
First boot sets everything up — the embedded Supermemory graph engine, local embeddings, and your credentials:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ url http://localhost:6767 │
|
||||
│ database ./.supermemory │
|
||||
│ api key sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
|
||||
│ org id xxxxxxxxxxxxxxxxxxxxxx │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Save that API key — it's your bearer token for every request.
|
||||
|
||||
<Note>
|
||||
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
|
||||
</Note>
|
||||
|
||||
## Add your first memory
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
import Supermemory from "supermemory"
|
||||
|
||||
const client = new Supermemory({
|
||||
apiKey: "sm_...",
|
||||
baseURL: "http://localhost:6767",
|
||||
})
|
||||
|
||||
await client.memories.add({
|
||||
content: "I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
|
||||
containerTag: "user_dhravya",
|
||||
})
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory(
|
||||
api_key="sm_...",
|
||||
base_url="http://localhost:6767",
|
||||
)
|
||||
|
||||
client.memories.add(
|
||||
content="I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
|
||||
container_tag="user_dhravya",
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl http://localhost:6767/v3/documents \
|
||||
-H "Authorization: Bearer sm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"content": "I am Dhravya. I love building dev tools and I am allergic to peanuts.",
|
||||
"containerTag": "user_dhravya"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Search it
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
const results = await client.search.memories({
|
||||
q: "what food should I avoid?",
|
||||
containerTag: "user_dhravya",
|
||||
})
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
results = client.search.memories(
|
||||
q="what food should I avoid?",
|
||||
container_tag="user_dhravya",
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="curl">
|
||||
```bash
|
||||
curl http://localhost:6767/v3/search \
|
||||
-H "Authorization: Bearer sm_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"q": "what food should I avoid?",
|
||||
"containerTag": "user_dhravya"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
That's it. Everything in the [Memory API](/quickstart) — documents, memories, user profiles, spaces, filtering — works identically against your local server.
|
||||
|
||||
## Where things live
|
||||
|
||||
By default, all state lives in a single directory you can back up or move:
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `./.supermemory/` (or `$SUPERMEMORY_DATA_DIR`) | The Supermemory graph engine's data, auth secret, embedding model cache |
|
||||
| `~/.supermemory/env` | API keys saved by the installer, loaded on every launch |
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
|
||||
LLM providers, local models, performance tuning
|
||||
</Card>
|
||||
<Card title="Memory API" icon="book-open" href="/quickstart">
|
||||
The full API — it all works against your local server
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
@ -170,6 +170,54 @@ The server will start at `http://localhost:8788`.
|
|||
|
||||
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
|
||||
core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` →
|
||||
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
|
||||
the `context` prompt, container-tag isolation, and auth rejections.
|
||||
|
||||
```bash
|
||||
export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it)
|
||||
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
|
||||
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
|
||||
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
|
||||
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
|
||||
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
|
||||
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
|
||||
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
|
||||
|
||||
#### OAuth flow tests
|
||||
|
||||
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
|
||||
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
|
||||
|
||||
- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize
|
||||
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
|
||||
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
|
||||
`/mcp` with it, exercising the OAuth-token validation path (not the `sm_` API-key path). It
|
||||
**skips** unless both env vars below are set.
|
||||
|
||||
```bash
|
||||
# One-time capture (opens a browser for login + consent, prints the env vars):
|
||||
bun e2e/capture-oauth-token.ts
|
||||
export SUPERMEMORY_MCP_CLIENT_ID=...
|
||||
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the
|
||||
refresh-token env vars — so CI is safe without secrets.
|
||||
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
|
||||
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
|
||||
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
|
|
|
|||
65
apps/mcp/e2e/auth.test.ts
Normal file
65
apps/mcp/e2e/auth.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { MCP_URL, ORIGIN } from "./helpers"
|
||||
|
||||
const initBody = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "smtest", version: "0.0.1" },
|
||||
},
|
||||
})
|
||||
|
||||
const mcpHeaders = (auth?: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
...(auth ? { Authorization: auth } : {}),
|
||||
})
|
||||
|
||||
// No API key needed — exercises the public surface and auth rejections.
|
||||
describe("MCP — transport & auth (raw HTTP)", () => {
|
||||
it("GET / returns service info", async () => {
|
||||
const res = await fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { name?: string; version?: string }
|
||||
expect(body.name).toBe("supermemory-mcp")
|
||||
expect(body.version).toBeTruthy()
|
||||
})
|
||||
|
||||
it("exposes OAuth protected-resource discovery", async () => {
|
||||
const res = await fetch(
|
||||
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
resource?: string
|
||||
authorization_servers?: string[]
|
||||
}
|
||||
expect(body.resource).toMatch(/\/mcp$/)
|
||||
expect(Array.isArray(body.authorization_servers)).toBe(true)
|
||||
expect(body.authorization_servers?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("rejects a request with no token (401 + WWW-Authenticate)", async () => {
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders(),
|
||||
body: initBody,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
|
||||
})
|
||||
|
||||
it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
|
||||
const res = await fetch(MCP_URL, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
|
||||
body: initBody,
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as { error?: { message?: string } }
|
||||
expect(body.error?.message).toMatch(/invalid|expired/i)
|
||||
})
|
||||
})
|
||||
103
apps/mcp/e2e/capture-oauth-token.ts
Normal file
103
apps/mcp/e2e/capture-oauth-token.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { exec } from "node:child_process"
|
||||
|
||||
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
|
||||
const PORT = 8765
|
||||
const REDIRECT_URI = `http://localhost:${PORT}/callback`
|
||||
|
||||
const b64url = (b: Buffer) =>
|
||||
b
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "")
|
||||
|
||||
async function main() {
|
||||
const meta = (await (
|
||||
await fetch(`${API_URL}/.well-known/oauth-authorization-server`)
|
||||
).json()) as {
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
registration_endpoint: string
|
||||
}
|
||||
|
||||
const reg = (await (
|
||||
await fetch(meta.registration_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "sm-mcp-e2e-capture",
|
||||
redirect_uris: [REDIRECT_URI],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
})
|
||||
).json()) as { client_id: string }
|
||||
|
||||
const verifier = b64url(randomBytes(32))
|
||||
const challenge = b64url(createHash("sha256").update(verifier).digest())
|
||||
const state = b64url(randomBytes(16))
|
||||
|
||||
const authUrl = new URL(meta.authorization_endpoint)
|
||||
authUrl.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: reg.client_id,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
state,
|
||||
}).toString()
|
||||
|
||||
const code: string = await new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
const u = new URL(req.url ?? "", `http://localhost:${PORT}`)
|
||||
if (u.pathname !== "/callback") return res.end()
|
||||
if (u.searchParams.get("state") !== state) {
|
||||
res.end("state mismatch")
|
||||
return reject(new Error("state mismatch"))
|
||||
}
|
||||
const c = u.searchParams.get("code")
|
||||
res.end("Done — you can close this tab.")
|
||||
server.close()
|
||||
c ? resolve(c) : reject(new Error("no code in callback"))
|
||||
}).listen(PORT, () => {
|
||||
console.log(`\nOpening browser to log in:\n${authUrl}\n`)
|
||||
exec(`open "${authUrl}" || xdg-open "${authUrl}"`)
|
||||
})
|
||||
})
|
||||
|
||||
const tokenRes = (await (
|
||||
await fetch(meta.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: reg.client_id,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
}),
|
||||
})
|
||||
).json()) as { refresh_token?: string; error?: string }
|
||||
|
||||
if (!tokenRes.refresh_token) {
|
||||
console.error("No refresh_token returned:", tokenRes)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("\nExport these to enable Tier D OAuth tests:\n")
|
||||
console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`)
|
||||
console.log(
|
||||
`export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
52
apps/mcp/e2e/discovery.test.ts
Normal file
52
apps/mcp/e2e/discovery.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"memory",
|
||||
"recall",
|
||||
"listProjects",
|
||||
"whoAmI",
|
||||
"memory-graph",
|
||||
]
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("handshakes and lists the expected tools", async () => {
|
||||
const { tools } = await s.client.listTools()
|
||||
const names = tools.map((t) => t.name)
|
||||
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
|
||||
})
|
||||
|
||||
it("lists profile & projects resources", async () => {
|
||||
const { resources } = await s.client.listResources()
|
||||
const uris = resources.map((r) => r.uri)
|
||||
expect(uris).toContain("supermemory://profile")
|
||||
expect(uris).toContain("supermemory://projects")
|
||||
})
|
||||
|
||||
it("lists the context prompt", async () => {
|
||||
const { prompts } = await s.client.listPrompts()
|
||||
expect(prompts.map((p) => p.name)).toContain("context")
|
||||
})
|
||||
|
||||
it("whoAmI resolves to the authenticated account", async () => {
|
||||
const res = await callTool(s.client, "whoAmI")
|
||||
expect(res.isError).toBeFalsy()
|
||||
const parsed = JSON.parse(textOf(res))
|
||||
expect(parsed.userId).toBeTruthy()
|
||||
})
|
||||
|
||||
it("listProjects returns content", async () => {
|
||||
const res = await callTool(s.client, "listProjects", { refresh: true })
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
59
apps/mcp/e2e/graph.test.ts
Normal file
59
apps/mcp/e2e/graph.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
|
||||
let s: Session
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("memory-graph returns a summary + structured documents", async () => {
|
||||
const res = await callTool(s.client, "memory-graph")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
|
||||
const sc = res.structuredContent as {
|
||||
documents?: unknown[]
|
||||
totalCount?: number
|
||||
}
|
||||
expect(Array.isArray(sc?.documents)).toBe(true)
|
||||
})
|
||||
|
||||
it("fetch-graph-data returns paginated documents", async () => {
|
||||
const res = await callTool(s.client, "fetch-graph-data", {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
const sc = res.structuredContent as {
|
||||
documents?: unknown[]
|
||||
pagination?: { limit?: number }
|
||||
}
|
||||
expect(Array.isArray(sc?.documents)).toBe(true)
|
||||
expect(sc?.pagination?.limit).toBe(5)
|
||||
})
|
||||
|
||||
it("reads the profile resource", async () => {
|
||||
const res = await s.client.readResource({ uri: "supermemory://profile" })
|
||||
expect(res.contents.length).toBeGreaterThan(0)
|
||||
expect(res.contents[0].mimeType).toBe("text/plain")
|
||||
expect(typeof res.contents[0].text).toBe("string")
|
||||
})
|
||||
|
||||
it("reads the projects resource as JSON", async () => {
|
||||
const res = await s.client.readResource({ uri: "supermemory://projects" })
|
||||
const text = res.contents[0].text as string
|
||||
const parsed = JSON.parse(text)
|
||||
expect(Array.isArray(parsed.projects)).toBe(true)
|
||||
})
|
||||
|
||||
it("gets the context prompt as a system message", async () => {
|
||||
const res = await s.client.getPrompt({ name: "context", arguments: {} })
|
||||
expect(res.messages.length).toBeGreaterThan(0)
|
||||
const text = res.messages[0].content.text as string
|
||||
expect(text).toMatch(/memory|context/i)
|
||||
})
|
||||
})
|
||||
186
apps/mcp/e2e/helpers.ts
Normal file
186
apps/mcp/e2e/helpers.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
|
||||
export const MCP_URL =
|
||||
process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
|
||||
export const API_KEY = process.env.SUPERMEMORY_API_KEY
|
||||
export const ORIGIN = new URL(MCP_URL).origin
|
||||
export const API_URL =
|
||||
process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts.
|
||||
export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN
|
||||
export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID
|
||||
|
||||
export type AuthServerMetadata = {
|
||||
authorization_endpoint: string
|
||||
token_endpoint: string
|
||||
registration_endpoint: string
|
||||
grant_types_supported?: string[]
|
||||
code_challenge_methods_supported?: string[]
|
||||
response_types_supported?: string[]
|
||||
}
|
||||
|
||||
// Walk the discovery chain a real MCP client follows: protected-resource → authorization server.
|
||||
export async function authServerMetadata(): Promise<{
|
||||
authServer: string
|
||||
metadata: AuthServerMetadata
|
||||
}> {
|
||||
const prRes = await fetch(
|
||||
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
|
||||
)
|
||||
const pr = (await prRes.json()) as { authorization_servers?: string[] }
|
||||
const authServer = pr.authorization_servers?.[0]
|
||||
if (!authServer) throw new Error("no authorization_servers in metadata")
|
||||
const metaRes = await fetch(
|
||||
`${authServer}/.well-known/oauth-authorization-server`,
|
||||
)
|
||||
return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata }
|
||||
}
|
||||
|
||||
export async function registerClient(registrationEndpoint: string): Promise<{
|
||||
status: number
|
||||
body: { client_id?: string; grant_types?: string[] }
|
||||
}> {
|
||||
const res = await fetch(registrationEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_name: "sm-mcp-e2e",
|
||||
redirect_uris: ["http://localhost:8765/callback"],
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "none",
|
||||
}),
|
||||
})
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
|
||||
export async function exchangeRefreshToken(
|
||||
tokenEndpoint: string,
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
): Promise<{
|
||||
status: number
|
||||
body: { access_token?: string; error?: string }
|
||||
}> {
|
||||
const res = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
}),
|
||||
})
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
|
||||
export type CallResult = {
|
||||
content?: Array<{ type: string; text?: string }>
|
||||
structuredContent?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export function textOf(res: CallResult): string {
|
||||
return (res.content ?? [])
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export type Session = { client: Client; close: () => Promise<void> }
|
||||
|
||||
export async function connect(
|
||||
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
|
||||
): Promise<Session> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
|
||||
}
|
||||
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
requestInit: { headers },
|
||||
})
|
||||
const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" })
|
||||
await client.connect(transport)
|
||||
return {
|
||||
client,
|
||||
close: () => transport.close().catch(() => {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function callTool(
|
||||
client: Client,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<CallResult> {
|
||||
return (await client.callTool({ name, arguments: args })) as CallResult
|
||||
}
|
||||
|
||||
// recall is eventually-consistent (save → ingestion pipeline → memories), so poll.
|
||||
export async function recallUntil(
|
||||
client: Client,
|
||||
query: string,
|
||||
needle: string,
|
||||
{
|
||||
tries = 18,
|
||||
delayMs = 5000,
|
||||
containerTag = undefined as string | undefined,
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "recall", {
|
||||
query,
|
||||
includeProfile: false,
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
})
|
||||
const txt = textOf(res)
|
||||
if (txt.includes(needle)) return txt
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// forget only matches extracted memory entries, not raw chunks, so a just-saved doc
|
||||
// returns "No matching memory found..." until extraction completes — poll for real removal.
|
||||
export async function forgetUntilForgotten(
|
||||
client: Client,
|
||||
content: string,
|
||||
{
|
||||
tries = 18,
|
||||
delayMs = 5000,
|
||||
containerTag = undefined as string | undefined,
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
})
|
||||
if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// poll until a memory is NO LONGER returned (for verifying forget).
|
||||
export async function recallUntilAbsent(
|
||||
client: Client,
|
||||
query: string,
|
||||
needle: string,
|
||||
{ tries = 12, delayMs = 5000 } = {},
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const res = await callTool(client, "recall", {
|
||||
query,
|
||||
includeProfile: false,
|
||||
})
|
||||
if (!textOf(res).includes(needle)) return true
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
127
apps/mcp/e2e/memory.test.ts
Normal file
127
apps/mcp/e2e/memory.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
callTool,
|
||||
connect,
|
||||
forgetUntilForgotten,
|
||||
recallUntil,
|
||||
recallUntilAbsent,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
|
||||
let s: Session
|
||||
const created: Array<{ content: string; containerTag?: string }> = []
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
for (const { content, containerTag } of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
}).catch(() => {})
|
||||
}
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("save → recall round-trips the saved memory", async () => {
|
||||
const marker = `rt-${randomUUID()}`
|
||||
const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.`
|
||||
created.push({ content })
|
||||
|
||||
const save = await callTool(s.client, "memory", { content, action: "save" })
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toMatch(/Saved memory/i)
|
||||
|
||||
const found = await recallUntil(s.client, "test fruit dragonfruit", marker)
|
||||
expect(found, `recall never returned marker ${marker}`).not.toBeNull()
|
||||
}, 120_000)
|
||||
|
||||
it("recall includeProfile=true returns profile + memories sections", async () => {
|
||||
const res = await callTool(s.client, "recall", {
|
||||
query: "dragonfruit",
|
||||
includeProfile: true,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
const txt = textOf(res)
|
||||
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
|
||||
}, 30_000)
|
||||
|
||||
// Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty.
|
||||
it("recall responds gracefully for an unmatched query", async () => {
|
||||
const res = await callTool(s.client, "recall", {
|
||||
query: `zzz-no-such-memory-${randomUUID()}`,
|
||||
includeProfile: false,
|
||||
})
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
|
||||
})
|
||||
|
||||
// Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort.
|
||||
it("forget accepts and removes a saved memory", async () => {
|
||||
const marker = `fg-${randomUUID()}`
|
||||
const content = `e2e forget target. token=${marker}. Secret animal is axolotl.`
|
||||
created.push({ content })
|
||||
|
||||
await callTool(s.client, "memory", { content, action: "save" })
|
||||
const found = await recallUntil(s.client, "secret animal axolotl", marker)
|
||||
expect(found, "memory should exist before forget").not.toBeNull()
|
||||
|
||||
// Polls forget until it confirms real removal ("forgot"), past the extraction window.
|
||||
const forgotten = await forgetUntilForgotten(s.client, content)
|
||||
expect(
|
||||
forgotten,
|
||||
`forget never confirmed removal for ${marker} (memory entry never extracted in time)`,
|
||||
).not.toBeNull()
|
||||
|
||||
const gone = await recallUntilAbsent(
|
||||
s.client,
|
||||
"secret animal axolotl",
|
||||
marker,
|
||||
)
|
||||
if (!gone) {
|
||||
console.warn(
|
||||
`[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`,
|
||||
)
|
||||
}
|
||||
}, 240_000)
|
||||
|
||||
it("containerTag scopes memories (isolation)", async () => {
|
||||
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
|
||||
const tagA = "sm_e2e_scope_a"
|
||||
const tagB = "sm_e2e_scope_b"
|
||||
const marker = `sc-${randomUUID()}`
|
||||
const content = `e2e scoping. token=${marker}. Project color is teal.`
|
||||
created.push({ content, containerTag: tagA })
|
||||
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "save",
|
||||
containerTag: tagA,
|
||||
})
|
||||
|
||||
const inA = await recallUntil(s.client, "project color teal", marker, {
|
||||
containerTag: tagA,
|
||||
})
|
||||
expect(inA, "marker should be found in its own container").not.toBeNull()
|
||||
|
||||
// Same query scoped to a different container must NOT see it.
|
||||
const leaked = await recallUntil(s.client, "project color teal", marker, {
|
||||
containerTag: tagB,
|
||||
tries: 3,
|
||||
delayMs: 3000,
|
||||
})
|
||||
expect(leaked, "marker leaked across containers").toBeNull()
|
||||
}, 120_000)
|
||||
|
||||
it("returns an error result for a missing required argument", async () => {
|
||||
const res = await callTool(s.client, "recall", {})
|
||||
expect(res.isError).toBe(true)
|
||||
expect(textOf(res).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
126
apps/mcp/e2e/oauth.test.ts
Normal file
126
apps/mcp/e2e/oauth.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
authServerMetadata,
|
||||
type AuthServerMetadata,
|
||||
callTool,
|
||||
connect,
|
||||
exchangeRefreshToken,
|
||||
OAUTH_CLIENT_ID,
|
||||
OAUTH_REFRESH_TOKEN,
|
||||
registerClient,
|
||||
type Session,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
|
||||
describe("MCP — OAuth protocol (no secrets)", () => {
|
||||
let meta: AuthServerMetadata
|
||||
|
||||
beforeAll(async () => {
|
||||
meta = (await authServerMetadata()).metadata
|
||||
})
|
||||
|
||||
// Tier A — the discovery chain a client walks from a 401 to the auth server.
|
||||
it("discovers the authorization server from protected-resource metadata", () => {
|
||||
expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
|
||||
expect(meta.token_endpoint).toMatch(/\/token$/)
|
||||
expect(meta.registration_endpoint).toMatch(/\/register$/)
|
||||
})
|
||||
|
||||
it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
|
||||
expect(meta.code_challenge_methods_supported).toContain("S256")
|
||||
expect(meta.response_types_supported).toContain("code")
|
||||
expect(meta.grant_types_supported).toContain("authorization_code")
|
||||
expect(meta.grant_types_supported).toContain("refresh_token")
|
||||
})
|
||||
|
||||
// Tier B — Dynamic Client Registration, the first authenticated-flow step.
|
||||
it("issues a client_id via dynamic client registration", async () => {
|
||||
const { status, body } = await registerClient(meta.registration_endpoint)
|
||||
expect(status).toBe(201)
|
||||
expect(body.client_id).toBeTruthy()
|
||||
expect(body.grant_types).toContain("refresh_token")
|
||||
})
|
||||
|
||||
// Tier C — token endpoint rejects forged grants with proper OAuth errors.
|
||||
it("rejects a bogus refresh_token with invalid_grant", async () => {
|
||||
const { status, body } = await exchangeRefreshToken(
|
||||
meta.token_endpoint,
|
||||
"bogus_rt_for_e2e",
|
||||
"bogus_client",
|
||||
)
|
||||
expect(status).toBe(401)
|
||||
expect(body.error).toBe("invalid_grant")
|
||||
})
|
||||
|
||||
it("rejects a bogus authorization code with invalid_grant", async () => {
|
||||
const res = await fetch(meta.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: "bogus_code",
|
||||
client_id: "bogus",
|
||||
code_verifier: "abc",
|
||||
redirect_uri: "http://localhost:8765/callback",
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
expect(((await res.json()) as { error?: string }).error).toBe(
|
||||
"invalid_grant",
|
||||
)
|
||||
})
|
||||
|
||||
it("redirects an unauthenticated authorize request to login", async () => {
|
||||
const url = new URL(meta.authorization_endpoint)
|
||||
url.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: "any",
|
||||
redirect_uri: "http://localhost:8765/callback",
|
||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method: "S256",
|
||||
scope: "openid profile email offline_access",
|
||||
state: "xyz",
|
||||
}).toString()
|
||||
const res = await fetch(url, { redirect: "manual" })
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers.get("location")).toMatch(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
|
||||
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
|
||||
"MCP — real OAuth token round-trip",
|
||||
() => {
|
||||
let s: Session
|
||||
let accessToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const { metadata } = await authServerMetadata()
|
||||
const { status, body } = await exchangeRefreshToken(
|
||||
metadata.token_endpoint,
|
||||
OAUTH_REFRESH_TOKEN as string,
|
||||
OAUTH_CLIENT_ID as string,
|
||||
)
|
||||
expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
|
||||
200,
|
||||
)
|
||||
expect(body.access_token).toBeTruthy()
|
||||
accessToken = body.access_token as string
|
||||
})
|
||||
afterAll(async () => {
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("mints an OAuth access token that is not an sm_ API key", () => {
|
||||
expect(accessToken.startsWith("sm_")).toBe(false)
|
||||
})
|
||||
|
||||
it("connects to /mcp with the OAuth token and resolves identity", async () => {
|
||||
s = await connect({ token: accessToken })
|
||||
const res = await callTool(s.client, "whoAmI")
|
||||
expect(res.isError).toBeFalsy()
|
||||
expect(JSON.parse(textOf(res)).userId).toBeTruthy()
|
||||
})
|
||||
},
|
||||
)
|
||||
80
apps/mcp/e2e/root-scope.test.ts
Normal file
80
apps/mcp/e2e/root-scope.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
|
||||
|
||||
type ToolLike = {
|
||||
name: string
|
||||
inputSchema?: { properties?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
|
||||
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
|
||||
|
||||
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
|
||||
const SCOPE_TAG = "sm_e2e_root"
|
||||
|
||||
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
|
||||
describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
|
||||
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
|
||||
const scoped = await connect({ containerTag: SCOPE_TAG })
|
||||
const plain = await connect()
|
||||
try {
|
||||
const scopedTools = (await scoped.client.listTools()).tools
|
||||
const plainTools = (await plain.client.listTools()).tools
|
||||
|
||||
expect(propsOf(plainTools, "memory")).toHaveProperty("containerTag")
|
||||
expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag")
|
||||
|
||||
expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
|
||||
expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag")
|
||||
} finally {
|
||||
await scoped.close()
|
||||
await plain.close()
|
||||
}
|
||||
})
|
||||
|
||||
it("scopes saves to the connection project and isolates them from default", async () => {
|
||||
const marker = `root-${randomUUID()}`
|
||||
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
|
||||
|
||||
const rooted = await connect({ containerTag: SCOPE_TAG })
|
||||
try {
|
||||
const save = await callTool(rooted.client, "memory", {
|
||||
content,
|
||||
action: "save",
|
||||
})
|
||||
expect(save.isError).toBeFalsy()
|
||||
expect(textOf(save)).toContain(SCOPE_TAG)
|
||||
|
||||
const found = await recallUntil(
|
||||
rooted.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
)
|
||||
expect(found, "marker not found within its root scope").not.toBeNull()
|
||||
} finally {
|
||||
await callTool(rooted.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
await rooted.close()
|
||||
}
|
||||
|
||||
// A default connection searches sm_project_default only — must not see it.
|
||||
const plain = await connect()
|
||||
try {
|
||||
const leaked = await recallUntil(
|
||||
plain.client,
|
||||
"root flower bluebell",
|
||||
marker,
|
||||
{
|
||||
tries: 3,
|
||||
delayMs: 3000,
|
||||
},
|
||||
)
|
||||
expect(leaked, "rooted memory leaked into the default project").toBeNull()
|
||||
} finally {
|
||||
await plain.close()
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
|
|
@ -8,7 +8,8 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
|
||||
"deploy": "vite build && wrangler deploy --minify",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"test:e2e": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-oauth-provider": "^0.2.2",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,29 @@ const DEFAULT_PROJECT_ID = "sm_project_default"
|
|||
const DEFAULT_LIST_LIMIT = 50
|
||||
const MAX_LIST_LIMIT = 200
|
||||
|
||||
interface MemoryRichFields {
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
isAggregated?: boolean
|
||||
}
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
| ({
|
||||
id: string
|
||||
memory: string
|
||||
similarity: number
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
| {
|
||||
} & MemoryRichFields)
|
||||
| ({
|
||||
id: string
|
||||
chunk: string
|
||||
similarity: number
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
} & MemoryRichFields)
|
||||
|
||||
export interface SearchResult {
|
||||
results: Memory[]
|
||||
|
|
@ -67,6 +75,17 @@ export interface ListMemoriesOptions {
|
|||
export interface ListMemoriesResult {
|
||||
memories: ListedMemory[]
|
||||
nextCursor: string | null
|
||||
export interface SearchOptions {
|
||||
searchMode?: "memories" | "hybrid" | "documents"
|
||||
rerank?: boolean
|
||||
rewriteQuery?: boolean
|
||||
include?: {
|
||||
documents?: boolean
|
||||
relatedMemories?: boolean
|
||||
summaries?: boolean
|
||||
chunks?: boolean
|
||||
forgottenMemories?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
|
@ -142,7 +161,11 @@ interface SDKResult {
|
|||
content?: string
|
||||
similarity: number
|
||||
title?: string
|
||||
context?: string
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedAt?: string
|
||||
context?: Record<string, unknown>
|
||||
documents?: Array<Record<string, unknown>>
|
||||
isAggregated?: boolean
|
||||
}
|
||||
|
||||
interface SDKListMemory {
|
||||
|
|
@ -377,26 +400,32 @@ export class SupermemoryClient {
|
|||
query: string,
|
||||
limit = 10,
|
||||
threshold?: number,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult> {
|
||||
try {
|
||||
const result = await this.client.search.memories({
|
||||
q: query,
|
||||
limit,
|
||||
containerTag: this.containerTag,
|
||||
searchMode: "hybrid",
|
||||
searchMode: options?.searchMode ?? "hybrid",
|
||||
threshold, // Optional threshold parameter
|
||||
rerank: options?.rerank,
|
||||
rewriteQuery: options?.rewriteQuery,
|
||||
include: options?.include,
|
||||
})
|
||||
|
||||
// Normalize and limit response size — preserve memory vs chunk distinction
|
||||
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const text = limitByChars(r.content || r.memory || r.chunk || "")
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
metadata: r.metadata,
|
||||
updatedAt: r.updatedAt,
|
||||
context: r.context,
|
||||
documents: r.documents,
|
||||
isAggregated: r.isAggregated,
|
||||
}
|
||||
if (r.chunk && !r.memory) {
|
||||
return { ...base, chunk: text }
|
||||
|
|
@ -468,9 +497,7 @@ export class SupermemoryClient {
|
|||
if (result.searchResults) {
|
||||
response.searchResults = {
|
||||
results: (result.searchResults.results as SDKResult[]).map((r) => {
|
||||
const text = limitByChars(
|
||||
r.content || r.memory || r.chunk || r.context || "",
|
||||
)
|
||||
const text = limitByChars(r.content || r.memory || r.chunk || "")
|
||||
const base = {
|
||||
id: r.id,
|
||||
similarity: r.similarity,
|
||||
|
|
|
|||
157
apps/mcp/src/format.ts
Normal file
157
apps/mcp/src/format.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
export function formatMemories(
|
||||
response: { results?: Array<Record<string, unknown>>; total?: number },
|
||||
opts: {
|
||||
minSimilarity?: number
|
||||
maxRelations?: number
|
||||
maxDocuments?: number
|
||||
maxChunkLength?: number
|
||||
includeScores?: boolean
|
||||
includeLegend?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
minSimilarity = 0,
|
||||
maxRelations = 4,
|
||||
maxDocuments = 3,
|
||||
maxChunkLength = Number.POSITIVE_INFINITY,
|
||||
includeScores = true,
|
||||
includeLegend = true,
|
||||
} = opts
|
||||
|
||||
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
|
||||
const mime = (m: string | undefined) =>
|
||||
!m
|
||||
? ""
|
||||
: m === "application/pdf"
|
||||
? "pdf"
|
||||
: m.includes("spreadsheet")
|
||||
? "xlsx"
|
||||
: m.includes("presentation")
|
||||
? "pptx"
|
||||
: m.includes("document")
|
||||
? "doc"
|
||||
: (m.split("/").pop() ?? "")
|
||||
|
||||
const temporal = (tc: Record<string, unknown> | undefined) => {
|
||||
if (!tc) return [] as string[]
|
||||
const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
|
||||
return [
|
||||
tc.documentDate && `doc ${day(tc.documentDate as string)}`,
|
||||
ev.length === 1 && `event ${ev[0]}`,
|
||||
ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`,
|
||||
].filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
const describeMeta = (m: Record<string, unknown> | undefined | null) => {
|
||||
if (!m) return ""
|
||||
const tags = [
|
||||
mime(m.mimeType as string | undefined),
|
||||
m.source as string | undefined,
|
||||
...temporal(m.temporalContext as Record<string, unknown> | undefined),
|
||||
].filter(Boolean)
|
||||
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
const renderRelations = (
|
||||
rels: Array<Record<string, unknown>> | undefined,
|
||||
arrow: string,
|
||||
root: string,
|
||||
) => {
|
||||
if (!rels?.length) return [] as string[]
|
||||
const seen = new Set<string>()
|
||||
const items = rels.filter((r) => {
|
||||
const k = (r.memory as string).trim()
|
||||
if (k === root.trim() || seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
const shown = items.slice(0, maxRelations)
|
||||
const lines = shown.map((r) => {
|
||||
const t = temporal(
|
||||
(r.metadata as Record<string, unknown> | undefined)?.temporalContext as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
)
|
||||
const when = t.length ? t.join(", ") : day(r.updatedAt as string)
|
||||
return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
|
||||
})
|
||||
if (items.length > shown.length)
|
||||
lines.push(` ${arrow} … +${items.length - shown.length} more`)
|
||||
return lines
|
||||
}
|
||||
|
||||
const renderDocs = (ds: Array<Record<string, unknown>> | undefined) =>
|
||||
(ds ?? []).slice(0, maxDocuments).map((d) => {
|
||||
const title = d.title ? `"${d.title}"` : "(untitled)"
|
||||
const type = d.type ? ` (${d.type})` : ""
|
||||
const summary = d.summary ? ` — ${d.summary}` : ""
|
||||
return ` Document: ${title}${type}${summary}`
|
||||
})
|
||||
|
||||
const results = (response.results ?? []).filter(
|
||||
(m) => ((m.similarity as number) ?? 0) >= minSimilarity,
|
||||
)
|
||||
if (!results.length) return "No relevant memories found."
|
||||
|
||||
const total = response.total ?? results.length
|
||||
const header = [
|
||||
`${results.length} memor${results.length === 1 ? "y" : "ies"}` +
|
||||
(total !== results.length ? ` of ${total}` : "") +
|
||||
", ranked by relevance.",
|
||||
includeLegend &&
|
||||
"Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const arrows = [
|
||||
["parents", "←"],
|
||||
["children", "→"],
|
||||
["related", "~"],
|
||||
] as const
|
||||
|
||||
const blocks = results.map((m) => {
|
||||
const score = (m.similarity as number)?.toFixed(2) ?? "—"
|
||||
const prefix = includeScores ? `${score} ` : ""
|
||||
const memory = (m.memory as string) ?? ""
|
||||
|
||||
if (m.isAggregated) return `${prefix}agg ${memory}`
|
||||
|
||||
if (m.chunk != null && m.memory == null) {
|
||||
const body = (m.chunk as string).replace(/\s+$/, "")
|
||||
const text =
|
||||
body.length > maxChunkLength
|
||||
? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
|
||||
: body
|
||||
return [
|
||||
`${prefix}chunk ${describeMeta(m.metadata as Record<string, unknown> | null)}`.trimEnd(),
|
||||
...renderDocs(
|
||||
m.documents as Array<Record<string, unknown>> | undefined,
|
||||
),
|
||||
...text.split("\n").map((l: string) => ` ${l}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
|
||||
const ctx = (m.context ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>
|
||||
return [
|
||||
`${prefix}${memory}`,
|
||||
meta
|
||||
? ` Source: ${meta}`
|
||||
: day(m.updatedAt as string)
|
||||
? ` Source: updated ${day(m.updatedAt as string)}`
|
||||
: null,
|
||||
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
|
||||
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
})
|
||||
|
||||
return [header, "", blocks.join("\n\n")].join("\n")
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@ import {
|
|||
registerAppResource,
|
||||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SupermemoryClient, getMemoryText } from "./client"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { formatMemories } from "./format"
|
||||
import { initPosthog, posthog } from "./posthog"
|
||||
import { z } from "zod"
|
||||
import mcpAppHtml from "../dist/mcp-app.html"
|
||||
|
|
@ -26,6 +27,8 @@ type Props = {
|
|||
|
||||
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const MAX_RECALL_CHARS = 200000
|
||||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTags: string[] = []
|
||||
|
|
@ -680,10 +683,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
const clientInfo = await this.getClientInfo()
|
||||
const startTime = Date.now()
|
||||
|
||||
if (includeProfile) {
|
||||
const profileResult = await client.getProfile(query)
|
||||
const parts: string[] = []
|
||||
const searchResult = await client.search(query, 10, undefined, {
|
||||
searchMode: "hybrid",
|
||||
include: {
|
||||
documents: true,
|
||||
relatedMemories: true,
|
||||
summaries: false,
|
||||
chunks: false,
|
||||
forgottenMemories: false,
|
||||
},
|
||||
})
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (includeProfile) {
|
||||
const profileResult = await client.getProfile()
|
||||
if (
|
||||
profileResult.profile.static.length > 0 ||
|
||||
profileResult.profile.dynamic.length > 0
|
||||
|
|
@ -701,54 +715,23 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
parts.push(`- ${fact}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileResult.searchResults?.results.length) {
|
||||
parts.push("\n## Relevant Memories")
|
||||
for (const [
|
||||
i,
|
||||
memory,
|
||||
] of profileResult.searchResults.results.entries()) {
|
||||
parts.push(
|
||||
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
|
||||
)
|
||||
if (memory.title) parts.push(`**${memory.title}**`)
|
||||
parts.push(getMemoryText(memory))
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = Date.now()
|
||||
|
||||
// Track search event
|
||||
posthog
|
||||
.memorySearch({
|
||||
query_length: query.length,
|
||||
results_count: profileResult.searchResults?.results.length || 0,
|
||||
search_duration_ms: endTime - startTime,
|
||||
container_tags_count: 1,
|
||||
source: "mcp",
|
||||
userId: this.props?.userId || "unknown",
|
||||
mcp_client_name: clientInfo?.name,
|
||||
mcp_client_version: clientInfo?.version,
|
||||
sessionId: this.getMcpSessionId(),
|
||||
containerTag: containerTag || this.props?.containerTag,
|
||||
})
|
||||
.catch((error) => console.error("PostHog tracking error:", error))
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
parts.length > 0
|
||||
? parts.join("\n")
|
||||
: "No memories or profile found.",
|
||||
},
|
||||
],
|
||||
parts.push("")
|
||||
}
|
||||
}
|
||||
|
||||
const searchResult = await client.search(query, 10)
|
||||
parts.push("## Relevant Memories")
|
||||
parts.push(
|
||||
formatMemories(
|
||||
{
|
||||
results: searchResult.results as unknown as Array<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
total: searchResult.total,
|
||||
},
|
||||
{ includeScores: true, includeLegend: true },
|
||||
),
|
||||
)
|
||||
|
||||
const endTime = Date.now()
|
||||
|
||||
// Track search event
|
||||
|
|
@ -767,22 +750,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
})
|
||||
.catch((error) => console.error("PostHog tracking error:", error))
|
||||
|
||||
if (searchResult.results.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "No memories found." }],
|
||||
}
|
||||
const text = parts.join("\n")
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
text.length > MAX_RECALL_CHARS
|
||||
? `${text.slice(0, MAX_RECALL_CHARS)}...`
|
||||
: text,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const parts = ["## Relevant Memories"]
|
||||
for (const [i, memory] of searchResult.results.entries()) {
|
||||
parts.push(
|
||||
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
|
||||
)
|
||||
if (memory.title) parts.push(`**${memory.title}**`)
|
||||
parts.push(getMemoryText(memory))
|
||||
}
|
||||
|
||||
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
|
|
|
|||
9
apps/mcp/vitest.config.ts
Normal file
9
apps/mcp/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts"],
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { apiKey } = await request.json()
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const containerTagsUrl = new URL(
|
||||
"/v3/container-tags/list",
|
||||
SUPERMEMORY_API_BASE_URL,
|
||||
)
|
||||
|
||||
const response = await fetch(containerTagsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.message || `API error: ${response.status}` },
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Container tags API error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch container tags" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
|
@ -9,6 +11,7 @@ export async function POST(request: Request) {
|
|||
limit = 500,
|
||||
sort = "createdAt",
|
||||
order = "desc",
|
||||
containerTags,
|
||||
} = body
|
||||
|
||||
if (!apiKey) {
|
||||
|
|
@ -18,23 +21,28 @@ export async function POST(request: Request) {
|
|||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.supermemory.ai/v3/documents/documents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
order,
|
||||
}),
|
||||
},
|
||||
const graphUrl = new URL(
|
||||
"/v3/documents/documents",
|
||||
SUPERMEMORY_API_BASE_URL,
|
||||
)
|
||||
|
||||
const response = await fetch(graphUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
order,
|
||||
...(Array.isArray(containerTags) && containerTags.length > 0
|
||||
? { containerTags }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,52 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import {
|
||||
MemoryGraph,
|
||||
type DocumentWithMemories,
|
||||
type GraphApiDocument,
|
||||
type GraphApiMemory,
|
||||
type GraphThemeColors,
|
||||
type MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
|
||||
|
||||
interface PlaygroundApiMemory {
|
||||
id: string
|
||||
memory?: string | null
|
||||
content?: string | null
|
||||
isStatic?: boolean
|
||||
spaceId?: string | null
|
||||
isLatest?: boolean
|
||||
isForgotten?: boolean
|
||||
forgetAfter?: string | null
|
||||
forgetReason?: string | null
|
||||
version?: number
|
||||
parentMemoryId?: string | null
|
||||
rootMemoryId?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
relation?: MemoryRelation | null
|
||||
updatesMemoryId?: string | null
|
||||
nextVersionId?: string | null
|
||||
memoryRelations?: Record<string, MemoryRelation> | null
|
||||
spaceContainerTag?: string | null
|
||||
}
|
||||
|
||||
interface PlaygroundApiDocument {
|
||||
id: string
|
||||
title: string | null
|
||||
summary?: string | null
|
||||
documentType?: string
|
||||
type?: string
|
||||
containerTags?: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
memories?: PlaygroundApiMemory[]
|
||||
memoryEntries?: PlaygroundApiMemory[]
|
||||
}
|
||||
|
||||
interface DocumentsResponse {
|
||||
documents: DocumentWithMemories[]
|
||||
documents: PlaygroundApiDocument[]
|
||||
pagination: {
|
||||
currentPage: number
|
||||
limit: number
|
||||
|
|
@ -19,42 +55,82 @@ interface DocumentsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
interface ContainerTagOption {
|
||||
id: string
|
||||
name?: string | null
|
||||
containerTag: string
|
||||
documentCount?: number
|
||||
memoryCount?: number
|
||||
lastActivityAt?: string | null
|
||||
}
|
||||
|
||||
type GraphVariant = "consumer" | "console"
|
||||
type LoadBehavior = "zoom" | "manual" | "background"
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const BACKGROUND_LOAD_DELAY_MS = 900
|
||||
const CONSUMER_GRAPH_COLORS = {
|
||||
bg: "transparent",
|
||||
edgeDerives: "#9ca3af",
|
||||
} satisfies Partial<GraphThemeColors>
|
||||
|
||||
/** Convert the external API format to the internal graph format */
|
||||
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.documentType,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: doc.memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.content,
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
|
||||
return docs.map((doc) => {
|
||||
const memories = doc.memories ?? doc.memoryEntries ?? []
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
summary: doc.summary ?? null,
|
||||
documentType: doc.documentType ?? doc.type ?? "unknown",
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
memories: memories.map(
|
||||
(mem): GraphApiMemory => ({
|
||||
id: mem.id,
|
||||
memory: mem.memory ?? mem.content ?? "",
|
||||
isStatic: mem.isStatic ?? false,
|
||||
spaceId: mem.spaceId ?? "",
|
||||
isLatest: mem.isLatest ?? true,
|
||||
isForgotten: mem.isForgotten ?? false,
|
||||
forgetAfter: mem.forgetAfter ?? null,
|
||||
forgetReason: mem.forgetReason ?? null,
|
||||
version: mem.version ?? 1,
|
||||
parentMemoryId: mem.parentMemoryId ?? null,
|
||||
rootMemoryId: mem.rootMemoryId ?? null,
|
||||
createdAt: mem.createdAt,
|
||||
updatedAt: mem.updatedAt,
|
||||
relation: mem.relation ?? null,
|
||||
updatesMemoryId: mem.updatesMemoryId ?? null,
|
||||
nextVersionId: mem.nextVersionId ?? null,
|
||||
memoryRelations: mem.memoryRelations ?? null,
|
||||
spaceContainerTag: mem.spaceContainerTag ?? null,
|
||||
}),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
|
||||
const [containerTag, setContainerTag] = useState("")
|
||||
const [containerTags, setContainerTags] = useState<ContainerTagOption[]>([])
|
||||
const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
|
||||
const [containerTagsError, setContainerTagsError] = useState<Error | null>(
|
||||
null,
|
||||
)
|
||||
const [documents, setDocuments] = useState<PlaygroundApiDocument[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [showGraph, setShowGraph] = useState(false)
|
||||
const [stressTestCount, setStressTestCount] = useState(0)
|
||||
const [graphVariant, setGraphVariant] = useState<GraphVariant>("consumer")
|
||||
const [loadBehavior, setLoadBehavior] = useState<LoadBehavior>("zoom")
|
||||
const [pagination, setPagination] = useState<
|
||||
DocumentsResponse["pagination"] | null
|
||||
>(null)
|
||||
|
||||
// State for slideshow
|
||||
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
|
||||
|
|
@ -64,13 +140,47 @@ export default function Home() {
|
|||
documents: GraphApiDocument[]
|
||||
} | null>(null)
|
||||
|
||||
const PAGE_SIZE = 500
|
||||
const selectedContainerTags = useMemo(() => {
|
||||
const trimmed = containerTag.trim()
|
||||
return trimmed ? [trimmed] : undefined
|
||||
}, [containerTag])
|
||||
|
||||
const fetchContainerTags = useCallback(async () => {
|
||||
if (!apiKey || isLoadingContainerTags) return
|
||||
|
||||
setIsLoadingContainerTags(true)
|
||||
setContainerTagsError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/container-tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Failed to fetch container tags")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ContainerTagOption[]
|
||||
setContainerTags(data)
|
||||
} catch (err) {
|
||||
setContainerTagsError(
|
||||
err instanceof Error ? err : new Error("Unknown error"),
|
||||
)
|
||||
} finally {
|
||||
setIsLoadingContainerTags(false)
|
||||
}
|
||||
}, [apiKey, isLoadingContainerTags])
|
||||
|
||||
const fetchDocuments = useCallback(
|
||||
async (page: number, append = false) => {
|
||||
if (!apiKey) return
|
||||
|
||||
if (page === 1) {
|
||||
if (append) {
|
||||
setIsLoadingMore(true)
|
||||
} else {
|
||||
setIsLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
|
@ -87,6 +197,7 @@ export default function Home() {
|
|||
limit: PAGE_SIZE,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: selectedContainerTags,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -103,6 +214,7 @@ export default function Home() {
|
|||
setDocuments(data.documents)
|
||||
}
|
||||
|
||||
setPagination(data.pagination)
|
||||
setShowGraph(true)
|
||||
setMockData(null)
|
||||
setStressTestCount(0)
|
||||
|
|
@ -110,19 +222,27 @@ export default function Home() {
|
|||
setError(err instanceof Error ? err : new Error("Unknown error"))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
},
|
||||
[apiKey],
|
||||
[apiKey, selectedContainerTags],
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (apiKey) {
|
||||
setDocuments([])
|
||||
setPagination(null)
|
||||
void fetchContainerTags()
|
||||
fetchDocuments(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadMoreDocuments = useCallback(() => {
|
||||
if (!pagination || pagination.currentPage >= pagination.totalPages) return
|
||||
fetchDocuments(pagination.currentPage + 1, true)
|
||||
}, [fetchDocuments, pagination])
|
||||
|
||||
const handleStressTest = (count: number) => {
|
||||
const data = generateMockGraphData({
|
||||
documentCount: count,
|
||||
|
|
@ -131,6 +251,7 @@ export default function Home() {
|
|||
})
|
||||
setMockData({ documents: data.documents })
|
||||
setDocuments([])
|
||||
setPagination(null)
|
||||
setStressTestCount(count)
|
||||
setShowGraph(true)
|
||||
setError(null)
|
||||
|
|
@ -157,7 +278,67 @@ export default function Home() {
|
|||
return toGraphDocuments(documents)
|
||||
}, [documents, mockData])
|
||||
|
||||
const availableContainerTags = useMemo(() => {
|
||||
const options = new Map<string, ContainerTagOption>()
|
||||
for (const tag of containerTags) {
|
||||
if (tag.containerTag) options.set(tag.containerTag, tag)
|
||||
}
|
||||
for (const doc of documents) {
|
||||
for (const tag of doc.containerTags ?? []) {
|
||||
if (tag && !options.has(tag)) {
|
||||
options.set(tag, { id: tag, containerTag: tag, name: tag })
|
||||
}
|
||||
}
|
||||
const memories = doc.memories ?? doc.memoryEntries ?? []
|
||||
for (const mem of memories) {
|
||||
const tag = mem.spaceContainerTag
|
||||
if (tag && !options.has(tag)) {
|
||||
options.set(tag, { id: tag, containerTag: tag, name: tag })
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...options.values()]
|
||||
}, [containerTags, documents])
|
||||
|
||||
const displayCount = mockData ? stressTestCount : documents.length
|
||||
const hasMore =
|
||||
!mockData &&
|
||||
pagination != null &&
|
||||
pagination.currentPage < pagination.totalPages
|
||||
const totalCount = mockData
|
||||
? stressTestCount
|
||||
: (pagination?.totalItems ?? documents.length)
|
||||
const maxNodes = mockData ? 1000 : undefined
|
||||
const graphHandlesLoadMore = loadBehavior === "zoom"
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
loadBehavior !== "background" ||
|
||||
!showGraph ||
|
||||
mockData ||
|
||||
!hasMore ||
|
||||
isLoading ||
|
||||
isLoadingMore ||
|
||||
error
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(
|
||||
handleLoadMoreDocuments,
|
||||
BACKGROUND_LOAD_DELAY_MS,
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [
|
||||
error,
|
||||
handleLoadMoreDocuments,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
loadBehavior,
|
||||
mockData,
|
||||
showGraph,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950">
|
||||
|
|
@ -178,9 +359,49 @@ export default function Home() {
|
|||
type="password"
|
||||
placeholder="Enter your Supermemory API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
setContainerTags([])
|
||||
setContainerTagsError(null)
|
||||
}}
|
||||
className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
list="container-tag-options"
|
||||
value={containerTag}
|
||||
onChange={(e) => setContainerTag(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (availableContainerTags.length === 0) {
|
||||
void fetchContainerTags()
|
||||
}
|
||||
}}
|
||||
disabled={!apiKey}
|
||||
placeholder={
|
||||
isLoadingContainerTags
|
||||
? "Loading container tags..."
|
||||
: "All container tags"
|
||||
}
|
||||
className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<datalist id="container-tag-options">
|
||||
{availableContainerTags.map((tag) => (
|
||||
<option key={tag.containerTag} value={tag.containerTag}>
|
||||
{tag.name && tag.name !== tag.containerTag
|
||||
? `${tag.name} (${tag.containerTag})`
|
||||
: tag.containerTag}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchContainerTags()}
|
||||
disabled={!apiKey || isLoadingContainerTags}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoadingContainerTags ? "Loading..." : "Tags"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!apiKey || isLoading}
|
||||
|
|
@ -205,8 +426,80 @@ export default function Home() {
|
|||
Stress Test Mode
|
||||
</span>
|
||||
)}
|
||||
{pagination && !mockData && (
|
||||
<span className="font-mono text-xs text-zinc-500">
|
||||
Page {pagination.currentPage}/{pagination.totalPages}
|
||||
</span>
|
||||
)}
|
||||
{selectedContainerTags && (
|
||||
<span className="rounded bg-sky-950/70 px-2 py-0.5 font-mono text-xs text-sky-300">
|
||||
{selectedContainerTags[0]}
|
||||
</span>
|
||||
)}
|
||||
{containerTagsError && (
|
||||
<span className="text-xs text-amber-300">
|
||||
{containerTagsError.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-zinc-500 text-xs">Mode:</span>
|
||||
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
|
||||
{(["consumer", "console"] as const).map((variant) => (
|
||||
<button
|
||||
key={variant}
|
||||
type="button"
|
||||
onClick={() => setGraphVariant(variant)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
graphVariant === variant
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
}`}
|
||||
aria-pressed={graphVariant === variant}
|
||||
>
|
||||
{variant}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
<span className="text-zinc-500 text-xs">Load:</span>
|
||||
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
|
||||
{(["zoom", "manual", "background"] as const).map((behavior) => (
|
||||
<button
|
||||
key={behavior}
|
||||
type="button"
|
||||
onClick={() => setLoadBehavior(behavior)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
|
||||
loadBehavior === behavior
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
|
||||
}`}
|
||||
aria-pressed={loadBehavior === behavior}
|
||||
>
|
||||
{behavior}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loadBehavior === "manual" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMoreDocuments}
|
||||
disabled={!hasMore || isLoadingMore}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore
|
||||
? "Loading..."
|
||||
: hasMore
|
||||
? "Load next"
|
||||
: "All loaded"}
|
||||
</button>
|
||||
)}
|
||||
{loadBehavior === "background" && !mockData && hasMore && (
|
||||
<span className="rounded bg-emerald-950/70 px-2 py-0.5 text-xs text-emerald-300">
|
||||
Auto paging
|
||||
</span>
|
||||
)}
|
||||
<div className="h-6 w-px bg-zinc-700" />
|
||||
{/* Stress test buttons */}
|
||||
<span className="text-zinc-500 text-xs">Stress Test:</span>
|
||||
{[50, 100, 200, 500].map((count) => (
|
||||
|
|
@ -300,13 +593,24 @@ export default function Home() {
|
|||
<MemoryGraph
|
||||
documents={graphDocuments}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={graphHandlesLoadMore && hasMore}
|
||||
onLoadMore={
|
||||
graphHandlesLoadMore && hasMore
|
||||
? handleLoadMoreDocuments
|
||||
: undefined
|
||||
}
|
||||
error={error}
|
||||
variant="consumer"
|
||||
maxNodes={1000}
|
||||
variant={graphVariant}
|
||||
maxNodes={maxNodes}
|
||||
showFps={stressTestCount > 0}
|
||||
isSlideshowActive={isSlideshowActive}
|
||||
onSlideshowNodeChange={handleSlideshowNodeChange}
|
||||
onSlideshowStop={handleSlideshowStop}
|
||||
totalCount={totalCount}
|
||||
colors={
|
||||
graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-zinc-400">
|
||||
|
|
|
|||
992
apps/web/app/(app)/brain-home/page.tsx
Normal file
992
apps/web/app/(app)/brain-home/page.tsx
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { Header } from "@/components/header"
|
||||
import { HomeChatComposer } from "@/components/chat/home-chat-composer"
|
||||
import {
|
||||
RecommendedRail,
|
||||
SourcesHealthRail,
|
||||
} from "@/components/brain-home/widgets"
|
||||
|
||||
const modalCardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
|
||||
const inputBevelStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
type Space = {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
itemCount: number
|
||||
lastSync: string
|
||||
}
|
||||
|
||||
const _SPACES: Space[] = [
|
||||
{
|
||||
id: "drive",
|
||||
name: "My Drive",
|
||||
icon: <GoogleDrive className="size-4" />,
|
||||
itemCount: 287,
|
||||
lastSync: "2h ago",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Company Notion",
|
||||
icon: <Notion className="size-4" />,
|
||||
itemCount: 142,
|
||||
lastSync: "12m ago",
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
icon: <Mail className="size-4 text-[#EA4335]" />,
|
||||
itemCount: 1843,
|
||||
lastSync: "Just now",
|
||||
},
|
||||
]
|
||||
|
||||
type Stat = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
deltaTint?: "up" | "down" | "neutral"
|
||||
}
|
||||
|
||||
const STATS: Stat[] = [
|
||||
{
|
||||
label: "Items ingested",
|
||||
value: "2,272",
|
||||
delta: "+148 this week",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Agent sessions",
|
||||
value: "184",
|
||||
delta: "+42 vs last wk",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Active contributors",
|
||||
value: "4",
|
||||
delta: "of 6 invited",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Top space",
|
||||
value: "Gmail",
|
||||
delta: "1,843 items",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
]
|
||||
|
||||
type AgentSeries = {
|
||||
key: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const AGENT_SERIES: AgentSeries[] = [
|
||||
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
|
||||
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
|
||||
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
|
||||
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
|
||||
{ key: "other", label: "Other", color: "#525D6E" },
|
||||
]
|
||||
|
||||
const SESSIONS_BY_DAY: {
|
||||
label: string
|
||||
values: Record<string, number>
|
||||
}[] = [
|
||||
{
|
||||
label: "Mon",
|
||||
values: {
|
||||
cursor: 8,
|
||||
claudeCode: 4,
|
||||
claudeDesktop: 3,
|
||||
chatgpt: 2,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Tue",
|
||||
values: {
|
||||
cursor: 10,
|
||||
claudeCode: 6,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Wed",
|
||||
values: {
|
||||
cursor: 12,
|
||||
claudeCode: 9,
|
||||
claudeDesktop: 5,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Thu",
|
||||
values: {
|
||||
cursor: 9,
|
||||
claudeCode: 5,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Fri",
|
||||
values: {
|
||||
cursor: 14,
|
||||
claudeCode: 10,
|
||||
claudeDesktop: 6,
|
||||
chatgpt: 4,
|
||||
other: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sat",
|
||||
values: {
|
||||
cursor: 5,
|
||||
claudeCode: 3,
|
||||
claudeDesktop: 2,
|
||||
chatgpt: 1,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sun",
|
||||
values: {
|
||||
cursor: 17,
|
||||
claudeCode: 12,
|
||||
claudeDesktop: 7,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const RECENT_ANSWERS = [
|
||||
{
|
||||
question: "What did we decide about pricing?",
|
||||
answer: "Pro stays at $20/mo. No overages, top-up only.",
|
||||
who: "You",
|
||||
when: "2h ago",
|
||||
},
|
||||
{
|
||||
question: "What's blocking the Acme deal?",
|
||||
answer: "MSA review — Jane drafting a net-30 counter.",
|
||||
who: "You",
|
||||
when: "4h ago",
|
||||
},
|
||||
{
|
||||
question: "Who owns the onboarding rewrite?",
|
||||
answer: "Design team. Ship target Friday.",
|
||||
who: "Sarah Kim",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
const WEEKLY_DIGEST = {
|
||||
range: "Mon – Fri",
|
||||
headline:
|
||||
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
|
||||
bullets: [
|
||||
{
|
||||
label: "5 deadlines coming up next week",
|
||||
tint: "text-[#4BA0FA]",
|
||||
},
|
||||
{
|
||||
label: "2 conflicts surfaced between Notion and Slack",
|
||||
tint: "text-[#FF8A47]",
|
||||
},
|
||||
{
|
||||
label: "Sarah Kim led contributions with 14 docs",
|
||||
tint: "text-[#A1A1AA]",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const TEAM_THIS_WEEK = [
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
email: "sarah@acme.com",
|
||||
contributions: 14,
|
||||
summary: "14 docs · 3 spaces",
|
||||
color: "#FF8A47",
|
||||
},
|
||||
{
|
||||
name: "Jane Doe",
|
||||
email: "jane@acme.com",
|
||||
contributions: 11,
|
||||
summary: "11 docs · 2 spaces",
|
||||
color: "#4BA0FA",
|
||||
},
|
||||
{
|
||||
name: "Mahesh S.",
|
||||
email: "mahesh@acme.com",
|
||||
contributions: 9,
|
||||
summary: "9 docs · 1 space",
|
||||
color: "#A1A1AA",
|
||||
},
|
||||
{
|
||||
name: "Alex Chen",
|
||||
email: "alex@acme.com",
|
||||
contributions: 7,
|
||||
summary: "7 docs · 2 spaces",
|
||||
color: "#10A37F",
|
||||
},
|
||||
]
|
||||
|
||||
const TEAM_RECENT = [
|
||||
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
|
||||
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
|
||||
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
|
||||
{
|
||||
who: "Sarah Kim",
|
||||
what: "connected Notion to Sales space",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIVITY = [
|
||||
{
|
||||
when: "2m",
|
||||
text: "23 new docs ingested from Drive",
|
||||
source: "drive",
|
||||
},
|
||||
{
|
||||
when: "14m",
|
||||
text: "Jane added a memo to acme-deal-q2",
|
||||
source: "notion",
|
||||
},
|
||||
{
|
||||
when: "1h",
|
||||
text: "Decision surfaced: pricing locked at $20 Pro",
|
||||
source: "slack",
|
||||
},
|
||||
{
|
||||
when: "3h",
|
||||
text: "14 Notion pages updated by Sarah Kim",
|
||||
source: "notion",
|
||||
},
|
||||
{
|
||||
when: "5h",
|
||||
text: "New entity formed: pricing-v3",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
when: "Yesterday",
|
||||
text: "MSA contract uploaded · auto-tagged contracts/MSA",
|
||||
source: "drive",
|
||||
},
|
||||
]
|
||||
|
||||
const _RECENT_ITEMS: Record<string, { title: string; meta: string }[]> = {
|
||||
drive: [
|
||||
{ title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" },
|
||||
{ title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" },
|
||||
{ title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" },
|
||||
{ title: "Customer feedback Q1.docx", meta: "Yesterday" },
|
||||
{ title: "Brand assets / logos", meta: "2 days ago" },
|
||||
],
|
||||
notion: [
|
||||
{ title: "Engineering / Weekly sync", meta: "Updated 12m ago" },
|
||||
{ title: "Sales / Acme account", meta: "Updated 1h ago" },
|
||||
{ title: "Brand / Voice & tone", meta: "Yesterday" },
|
||||
{ title: "Onboarding rewrite spec", meta: "2 days ago" },
|
||||
],
|
||||
gmail: [
|
||||
{ title: "Re: Acme contract review", meta: "Just now · jane@acme.com" },
|
||||
{ title: "Customer feedback digest", meta: "10m ago" },
|
||||
{ title: "Re: Pricing decision", meta: "1h ago" },
|
||||
{ title: "Onboarding ship plan", meta: "3h ago" },
|
||||
],
|
||||
}
|
||||
|
||||
export default function BrainHomePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<BrainHomeInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainHomeInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const empty = searchParams?.get("empty") === "1"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh bg-[#05080D] text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={false}
|
||||
topPosition="20%"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
|
||||
<div
|
||||
id="brain-home-grid"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.22)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Header onAddMemory={() => {}} onOpenSearch={() => {}} />
|
||||
|
||||
<main className="relative z-10 px-6 md:px-10 py-8 pb-[180px]">
|
||||
<div className="max-w-[1280px] mx-auto space-y-6">
|
||||
<StatsStrip stats={STATS} empty={empty} />
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_320px] gap-6">
|
||||
<div className="space-y-8 min-w-0">
|
||||
<BrainPulseBlock empty={empty} />
|
||||
<BrainActivityChart empty={empty} />
|
||||
<ActivityFeedBlock empty={empty} />
|
||||
</div>
|
||||
|
||||
<aside className="lg:sticky lg:top-6 lg:self-start h-fit space-y-4">
|
||||
<TeamActivityRail empty={empty} />
|
||||
<SourcesHealthRail empty={empty} />
|
||||
<RecommendedRail />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-56 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30">
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) {
|
||||
const EMPTY_STATS: Stat[] = [
|
||||
{
|
||||
label: "Items ingested",
|
||||
value: "—",
|
||||
delta: "Connect a source",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Agent sessions",
|
||||
value: "—",
|
||||
delta: "Install an agent",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Active contributors",
|
||||
value: "1",
|
||||
delta: "Just you so far",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Top space",
|
||||
value: "—",
|
||||
delta: "Nothing connected",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
]
|
||||
const visible = empty ? EMPTY_STATS : stats
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{visible.map((s) => (
|
||||
<div key={s.label} className="px-5 py-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{s.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold mt-1.5 tabular-nums leading-none",
|
||||
empty && s.value === "—" ? "text-[#525D6E]" : "text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
{s.delta && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-medium mt-1.5",
|
||||
s.deltaTint === "up"
|
||||
? "text-[#4BA0FA]"
|
||||
: s.deltaTint === "down"
|
||||
? "text-[#FF8A47]"
|
||||
: "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{s.delta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainActivityChart({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sessions by agent
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
0 sessions yet — once you install Cursor, Claude Code, or any
|
||||
MCP-capable agent, usage shows up here.
|
||||
</p>
|
||||
<div className="mt-6 flex items-end gap-2 opacity-30" aria-hidden>
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
|
||||
<div key={d} className="flex-1 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-[4px] bg-[#1F2937]"
|
||||
style={{ height: "4px" }}
|
||||
/>
|
||||
<span className="text-[10px] text-[#525D6E] font-medium">
|
||||
{d}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 text-[12px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Install an agent <ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const totalsByAgent = AGENT_SERIES.map((s) => ({
|
||||
...s,
|
||||
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
|
||||
}))
|
||||
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
|
||||
const dayTotals = SESSIONS_BY_DAY.map((d) =>
|
||||
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
|
||||
)
|
||||
const max = Math.max(...dayTotals)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sessions by agent
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
{grandTotal} agent sessions, last 7 days — which tool the team
|
||||
reached for.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
|
||||
{totalsByAgent.map((s) => (
|
||||
<span
|
||||
key={s.key}
|
||||
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
{s.label}{" "}
|
||||
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{SESSIONS_BY_DAY.map((d, dayIdx) => {
|
||||
const BAR_AREA_PX = 140
|
||||
const totalForDay = dayTotals[dayIdx] ?? 0
|
||||
const barHeightPx = Math.max(
|
||||
6,
|
||||
Math.round((totalForDay / max) * BAR_AREA_PX),
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={d.label}
|
||||
className="flex-1 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-full flex items-end"
|
||||
style={{ height: `${BAR_AREA_PX}px` }}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-[4px] overflow-hidden flex flex-col"
|
||||
style={{ height: `${barHeightPx}px` }}
|
||||
title={`${d.label}: ${totalForDay} sessions`}
|
||||
>
|
||||
{AGENT_SERIES.map((series, sIdx) => {
|
||||
const val = d.values[series.key] ?? 0
|
||||
if (val === 0) return null
|
||||
const segHeight =
|
||||
totalForDay > 0 ? (val / totalForDay) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
key={series.key}
|
||||
style={{
|
||||
height: `${segHeight}%`,
|
||||
background: series.color,
|
||||
}}
|
||||
className={cn(
|
||||
sIdx === 0 && "rounded-t-[4px]",
|
||||
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
|
||||
)}
|
||||
title={`${series.label} · ${val}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function _TabButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count: number
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-2 rounded-full px-3 text-[12px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-white/[0.10] text-[#fafafa]"
|
||||
: "text-[#A1A1AA] hover:text-[#fafafa] hover:bg-white/[0.04]",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="leading-none">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold tabular-nums leading-none",
|
||||
active ? "text-[#A1A1AA]" : "text-[#525D6E]",
|
||||
)}
|
||||
>
|
||||
{count.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ title, cta }: { title: string; cta?: string }) {
|
||||
return (
|
||||
<div className="flex items-end justify-between mb-3 px-1">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{title}
|
||||
</p>
|
||||
{cta && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainPulseBlock({ empty }: { empty?: boolean }) {
|
||||
return (
|
||||
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
|
||||
<RecentAnswersCard empty={empty} />
|
||||
<WeeklyDigestCard empty={empty} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentAnswersCard({ empty }: { empty?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
Recent answers
|
||||
</p>
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
See all →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{empty ? (
|
||||
<div className="flex-1 flex flex-col items-start justify-center py-4 gap-1.5">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium">
|
||||
No questions asked yet.
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium leading-[1.5]">
|
||||
Ask the brain something below — answers and the team's history will
|
||||
show up here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{RECENT_ANSWERS.map((a) => (
|
||||
<li
|
||||
key={a.question}
|
||||
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
|
||||
>
|
||||
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
|
||||
{a.question}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
|
||||
→ {a.answer}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#737373] font-medium mt-1">
|
||||
{a.who} · {a.when}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeeklyDigestCard({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-3">
|
||||
This week
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold text-[#fafafa] leading-snug",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Your brain is just getting started.
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium leading-[1.5] mt-2">
|
||||
Once sources are connected and the team starts asking questions,
|
||||
you'll see decisions, deadlines, and contributor highlights here.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto pt-4 self-start text-[11px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Connect a source <ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
|
||||
{WEEKLY_DIGEST.range}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold text-[#fafafa] leading-snug",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
|
||||
/^\d+$/.test(piece) ? (
|
||||
<span key={i} className="text-[#4BA0FA]">
|
||||
{piece}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{piece}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{WEEKLY_DIGEST.bullets.map((b) => (
|
||||
<li key={b.label} className="flex items-start gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
|
||||
/>
|
||||
<span className={cn("text-[12px] font-medium", b.tint)}>
|
||||
{b.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Open weekly digest <ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityFeedBlock({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Activity feed" />
|
||||
<div
|
||||
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-6 flex items-start gap-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
|
||||
<Sparkles className="size-3.5 text-[#525D6E]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium">
|
||||
Nothing happening yet.
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-1 leading-[1.5]">
|
||||
As sources sync and the team starts using the brain, every event
|
||||
will show up here in real time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const sourceIcon = (s: string) => {
|
||||
if (s === "drive") return <GoogleDrive className="size-3.5" />
|
||||
if (s === "notion") return <Notion className="size-3.5" />
|
||||
if (s === "slack")
|
||||
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
|
||||
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Activity feed" cta="Show more →" />
|
||||
<div
|
||||
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{ACTIVITY.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
|
||||
{sourceIcon(a.source)}
|
||||
</div>
|
||||
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
|
||||
{a.text}
|
||||
</p>
|
||||
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
|
||||
{a.when}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamActivityRail({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Just you so far
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
|
||||
A brain gets sharper as more people contribute. Invite teammates to
|
||||
see who's adding what.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Invite teammates
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Team activity
|
||||
</p>
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#737373] font-medium">
|
||||
What your team's been adding to the brain.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{TEAM_THIS_WEEK.map((member) => (
|
||||
<div
|
||||
key={member.email}
|
||||
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div
|
||||
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
|
||||
style={{ background: member.color }}
|
||||
>
|
||||
{member.name[0]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium truncate">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium truncate">
|
||||
{member.summary}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
|
||||
{member.contributions}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-white/[0.04]">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
|
||||
Recent
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{TEAM_RECENT.map((event, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
|
||||
>
|
||||
<span className="text-[#fafafa]">{event.who}</span> {event.what}
|
||||
<span className="text-[#525D6E]"> · {event.when}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Invite teammates
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsModalProvider>
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<PWAInstallPrompt />
|
||||
</>
|
||||
</SettingsModalProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ import { useQueryState } from "nuqs"
|
|||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { MobileBottomNav } from "@/components/bottom-nav"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
|
|
@ -24,6 +25,7 @@ import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
|
|||
import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { DocumentModal } from "@/components/document-modal"
|
||||
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
||||
|
|
@ -106,7 +108,7 @@ function ViewErrorFallback() {
|
|||
|
||||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
const { user, session, isSessionPending } = useAuth()
|
||||
|
||||
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
|
|
@ -166,6 +168,9 @@ export default function NewPage() {
|
|||
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [queuedChatAttachments, setQueuedChatAttachments] = useState<
|
||||
ChatAttachmentDraft[] | null
|
||||
>(null)
|
||||
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
|
@ -494,6 +499,7 @@ export default function NewPage() {
|
|||
setQueuedChatModel(null)
|
||||
setQueuedChatReasoningEffort(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedChatAttachments(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
|
|
@ -506,12 +512,14 @@ export default function NewPage() {
|
|||
model: ModelId,
|
||||
projectId: string,
|
||||
reasoningEffort: ReasoningEffort,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => {
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedChatReasoningEffort(reasoningEffort)
|
||||
setQueuedChatProject(projectId)
|
||||
setQueuedChatAttachments(attachments ?? null)
|
||||
setQueuedMessageSource("home")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
|
|
@ -523,6 +531,7 @@ export default function NewPage() {
|
|||
setQueuedChatModel(null)
|
||||
setQueuedChatReasoningEffort(null)
|
||||
setQueuedChatProject(null)
|
||||
setQueuedChatAttachments(null)
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
|
@ -572,17 +581,20 @@ export default function NewPage() {
|
|||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
const isGraphMode = viewMode === "graph"
|
||||
const showBottomNav = isMobile && !!session
|
||||
const showBottomNav = isMobile && !!session && !isChatView
|
||||
const isPublicIntegrations =
|
||||
!session && !isSessionPending && viewMode === "integrations"
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<OnboardingConfetti />
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
(isGraphMode || isChatView) && "h-dvh overflow-hidden",
|
||||
showBottomNav &&
|
||||
!isGraphMode &&
|
||||
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
|
||||
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
>
|
||||
{showNovaBackdrop && (
|
||||
|
|
@ -598,7 +610,9 @@ export default function NewPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{!session && viewMode === "mcp" ? (
|
||||
{isPublicIntegrations ? (
|
||||
<PublicHeader variant="integrations" />
|
||||
) : !session && viewMode === "mcp" ? (
|
||||
<PublicHeader />
|
||||
) : (
|
||||
<Header
|
||||
|
|
@ -642,6 +656,7 @@ export default function NewPage() {
|
|||
queuedHighlightContent={queuedHighlightContent}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
queuedAttachments={queuedChatAttachments}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
initialReasoningEffort={queuedChatReasoningEffort}
|
||||
initialChatProject={queuedChatProject}
|
||||
|
|
@ -649,7 +664,10 @@ export default function NewPage() {
|
|||
</div>
|
||||
) : viewMode === "integrations" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
||||
<IntegrationsView />
|
||||
<IntegrationsView
|
||||
publicMode={isPublicIntegrations}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
|
|
@ -684,7 +702,7 @@ export default function NewPage() {
|
|||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
|
|
@ -761,7 +779,7 @@ export default function NewPage() {
|
|||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30",
|
||||
showBottomNav
|
||||
? "bottom-[4.25rem]"
|
||||
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
|
||||
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,756 +1,23 @@
|
|||
"use client"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef, useMemo } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Sun,
|
||||
LoaderIcon,
|
||||
User as UserIcon,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
ShieldAlert,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Building2,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import {
|
||||
PLAN_DISPLAY_NAMES,
|
||||
useTokenUsage,
|
||||
type PlanType,
|
||||
} from "@/hooks/use-token-usage"
|
||||
import { parseHashToTab } from "@/components/settings/settings-content"
|
||||
|
||||
const TABS = [
|
||||
"account",
|
||||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"support",
|
||||
] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
||||
type NavItem = {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account",
|
||||
description: "Your profile and organization",
|
||||
icon: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
? (cleaned as SettingsTab)
|
||||
: "account"
|
||||
}
|
||||
|
||||
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
|
||||
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
|
||||
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
|
||||
max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]",
|
||||
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
|
||||
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
|
||||
}
|
||||
|
||||
function OrgPlanBadge({ plan }: { plan: PlanType }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
||||
ORG_PLAN_BADGE_STYLES[plan],
|
||||
)}
|
||||
>
|
||||
{PLAN_DISPLAY_NAMES[plan]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOrgPlan(
|
||||
orgId: string,
|
||||
isCurrent: boolean,
|
||||
currentPlan: PlanType,
|
||||
planByOrgId: Map<string, PlanType>,
|
||||
): PlanType {
|
||||
const fromSummary = planByOrgId.get(orgId)
|
||||
if (fromSummary) return fromSummary
|
||||
if (isCurrent) return currentPlan
|
||||
return "free"
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentityCard({ displayName }: { displayName: string }) {
|
||||
const firstName = displayName?.split(" ")[0] || ""
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
/**
|
||||
* Legacy redirect: /settings (and /settings#billing etc.) now open the
|
||||
* settings modal via the ?settings=<tab> URL state on the home page.
|
||||
*/
|
||||
export default function SettingsRedirect() {
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
|
||||
const [resetConfirmation, setResetConfirmation] = useState("")
|
||||
const resetOrganization = useResetOrganization()
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
|
||||
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const canSwitchOrg = (organizations?.length ?? 0) > 1
|
||||
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
for (const summary of orgSummaries ?? []) {
|
||||
map.set(summary.orgId, summary.plan)
|
||||
}
|
||||
return map
|
||||
}, [orgSummaries])
|
||||
const activeOrgPlan = org?.id
|
||||
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
|
||||
: currentPlan
|
||||
|
||||
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
|
||||
if (orgId === org?.id) {
|
||||
setOrgSwitcherOpen(false)
|
||||
return
|
||||
}
|
||||
setSwitchingOrgId(orgId)
|
||||
try {
|
||||
await setActiveOrg(orgSlug)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("Failed to switch organization:", error)
|
||||
setSwitchingOrgId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteEmailConfirm !== user?.email) return
|
||||
deleteUserAccount.mutate(
|
||||
{ confirmation: deleteEmailConfirm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteEmailConfirm("")
|
||||
router.push("/login")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return
|
||||
hasInitialized.current = true
|
||||
|
||||
const hash = window.location.hash
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : ""
|
||||
const tab = parseHashToTab(hash)
|
||||
setActiveTab(tab)
|
||||
analytics.settingsTabChanged({ tab })
|
||||
router.replace(
|
||||
tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`,
|
||||
)
|
||||
}, [router])
|
||||
|
||||
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
|
||||
window.history.pushState(null, "", "#account")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const tab = parseHashToTab(window.location.hash)
|
||||
setActiveTab(tab)
|
||||
analytics.settingsTabChanged({ tab })
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const headerDisplayName =
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
|
||||
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className={cn(
|
||||
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
|
||||
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
<span className="ml-2 font-semibold text-white/90 tracking-tight">
|
||||
supermemory
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-white/30 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/55 font-medium shrink-0">Settings</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
{!isMobile &&
|
||||
(canSwitchOrg ? (
|
||||
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
"cursor-pointer hover:bg-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{[...(organizations ?? [])]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingOrgId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
handleOrgSwitch(organization.slug, organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/5"
|
||||
: "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
</div>
|
||||
))}
|
||||
<UserProfileMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
|
||||
{!isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={headerDisplayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
|
||||
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
analytics.settingsTabChanged({ tab: item.id })
|
||||
}}
|
||||
className={cn(
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">
|
||||
Reset data
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
<p className="text-muted-foreground p-4">
|
||||
Something went wrong loading this section.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => setResetConfirmation(e.target.value)}
|
||||
placeholder={confirmText || "Your name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsResetDialogOpen(false)
|
||||
setResetConfirmation("")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{resetOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
|
||||
placeholder={user?.email ?? "your@email.com"}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
1
apps/web/app/auth/agent-connect/page.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../connect/page"
|
||||
|
|
@ -99,7 +99,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Captures coding decisions and patterns automatically",
|
||||
"Builds persistent user profile across projects",
|
||||
],
|
||||
icon: "/images/plugins/codex.svg",
|
||||
icon: "/images/plugins/codex.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +199,7 @@ function AuthConnectContent() {
|
|||
|
||||
const redirectUrl = new URL(callback)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
console.error("Failed to get API key:", err)
|
||||
|
|
@ -395,7 +396,7 @@ function AuthConnectContent() {
|
|||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/?view=plugins"
|
||||
href="https://app.supermemory.ai/settings#billing"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,27 @@ export const metadata: Metadata = {
|
|||
],
|
||||
},
|
||||
manifest: "/manifest.webmanifest",
|
||||
openGraph: {
|
||||
description: "Your memories, wherever you are",
|
||||
images: [
|
||||
{
|
||||
url: "/OG.png",
|
||||
width: 1800,
|
||||
height: 945,
|
||||
alt: "supermemory app",
|
||||
},
|
||||
],
|
||||
siteName: "supermemory",
|
||||
title: "supermemory app",
|
||||
type: "website",
|
||||
},
|
||||
title: "supermemory app",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
description: "Your memories, wherever you are",
|
||||
images: ["/OG.png"],
|
||||
title: "supermemory app",
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
|
|
|||
|
|
@ -328,11 +328,11 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const handleUpgrade = async (planId: "api_pro" | "api_max" = "api_pro") => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
planId,
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
|
|
@ -614,11 +614,21 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setGranolaModalOpen(true)}
|
||||
disabled={!isMaxUser}
|
||||
onClick={() => {
|
||||
if (!isMaxUser) {
|
||||
handleUpgrade("api_max")
|
||||
return
|
||||
}
|
||||
setGranolaModalOpen(true)
|
||||
}}
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
|
||||
>
|
||||
Connect
|
||||
{!isMaxUser
|
||||
? isUpgrading || autumn.isLoading
|
||||
? "Upgrading..."
|
||||
: "Upgrade"
|
||||
: "Connect"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -783,8 +793,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!isMaxUser}
|
||||
onClick={() => setGranolaModalOpen(true)}
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
onClick={() => {
|
||||
if (!isMaxUser) {
|
||||
handleUpgrade("api_max")
|
||||
return
|
||||
}
|
||||
setGranolaModalOpen(true)
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100 data-disabled:opacity-40 data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent"
|
||||
>
|
||||
<Granola className="size-5 mt-0.5 shrink-0" />
|
||||
|
|
@ -798,7 +814,9 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
)}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
Meeting notes & transcripts
|
||||
{isMaxUser
|
||||
? "Meeting notes & transcripts"
|
||||
: "Upgrade to Max"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
|
|||
import { toast } from "sonner"
|
||||
|
||||
export const FILE_ACCEPT =
|
||||
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
|
||||
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,.html,.htm,text/markdown,application/json,text/html"
|
||||
|
||||
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
|
||||
|
||||
|
|
@ -46,10 +46,15 @@ function isAcceptedFile(file: File): boolean {
|
|||
".txt",
|
||||
".md",
|
||||
".mdx",
|
||||
".json",
|
||||
".html",
|
||||
".htm",
|
||||
])
|
||||
if (allowedExt.has(ext)) return true
|
||||
if (file.type.startsWith("image/")) return true
|
||||
if (file.type === "text/markdown") return true
|
||||
if (file.type === "application/json") return true
|
||||
if (file.type === "text/html") return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +62,18 @@ function fileQueueKey(file: File): string {
|
|||
return `${file.name}:${file.size}:${file.lastModified}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ["KB", "MB", "GB", "TB"]
|
||||
let size = bytes / 1024
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export function FileContent({
|
||||
data,
|
||||
onDataChange,
|
||||
|
|
@ -197,7 +214,7 @@ export function FileContent({
|
|||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
<p className="text-[16px] font-medium">Upload files</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
Images, PDF, documents, sheets, markdown
|
||||
Images, PDF, documents, sheets, markdown, HTML
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
|
|
@ -284,7 +301,7 @@ export function FileContent({
|
|||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-[#737373] text-xs">
|
||||
{(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
{item.status === "error" && item.errorMessage ? (
|
||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon, Loader2 } from "lucide-react"
|
||||
|
|
@ -10,6 +10,7 @@ import { Button } from "@ui/components/button"
|
|||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
const EMOJI_LIST = [
|
||||
"📁",
|
||||
|
|
@ -62,6 +63,25 @@ const EMOJI_LIST = [
|
|||
"🤍",
|
||||
]
|
||||
|
||||
export const CONTEXT_PRESETS: { label: string; text: string }[] = [
|
||||
{
|
||||
label: "Work project",
|
||||
text: "Tracks a work project — decisions, owners, deadlines, and current status.",
|
||||
},
|
||||
{
|
||||
label: "Client",
|
||||
text: "About a client — meetings, requirements, and account context.",
|
||||
},
|
||||
{
|
||||
label: "Research",
|
||||
text: "Research notes — sources, key findings, and open questions.",
|
||||
},
|
||||
{
|
||||
label: "Personal",
|
||||
text: "My personal space — notes, ideas, and things to remember.",
|
||||
},
|
||||
]
|
||||
|
||||
export function AddSpaceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
|
@ -72,6 +92,8 @@ export function AddSpaceModal({
|
|||
onCreated?: (containerTag: string) => void
|
||||
}) {
|
||||
const [spaceName, setSpaceName] = useState("")
|
||||
const [spaceContext, setSpaceContext] = useState("")
|
||||
const [showContext, setShowContext] = useState(false)
|
||||
const [emoji, setEmoji] = useState("📁")
|
||||
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
|
||||
const { createProjectMutation } = useProjectMutations()
|
||||
|
|
@ -79,6 +101,8 @@ export function AddSpaceModal({
|
|||
const handleClose = () => {
|
||||
onClose()
|
||||
setSpaceName("")
|
||||
setSpaceContext("")
|
||||
setShowContext(false)
|
||||
setEmoji("📁")
|
||||
}
|
||||
|
||||
|
|
@ -89,11 +113,18 @@ export function AddSpaceModal({
|
|||
createProjectMutation.mutate(
|
||||
{ name: trimmedName, emoji: emoji || undefined },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSuccess: async (data) => {
|
||||
analytics.spaceCreated()
|
||||
if (data?.containerTag) {
|
||||
onCreated?.(data.containerTag)
|
||||
const tag = data?.containerTag
|
||||
const context = showContext ? spaceContext.trim() : ""
|
||||
if (tag && context) {
|
||||
try {
|
||||
await $fetch(`@patch/container-tags/${tag}`, {
|
||||
body: { entityContext: context },
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
if (tag) onCreated?.(tag)
|
||||
handleClose()
|
||||
},
|
||||
},
|
||||
|
|
@ -132,21 +163,21 @@ export function AddSpaceModal({
|
|||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Create new space
|
||||
</p>
|
||||
New space
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[16px] leading-[1.35]",
|
||||
dmSansClassName(),
|
||||
"text-[#737373] text-[13px] leading-snug",
|
||||
)}
|
||||
>
|
||||
Create spaces to organize your memories and documents and create
|
||||
a context rich environment
|
||||
Group related memories and give Nova context for this space.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
|
|
@ -222,6 +253,71 @@ export function AddSpaceModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{!showContext ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContext(true)}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex cursor-pointer items-center gap-1.5 self-start pl-1 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
<span className="text-[16px] leading-none text-[#737373]">+</span>
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
Tell Nova what to remember
|
||||
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
|
||||
New
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 pl-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] font-medium text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
What to remember
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-[#525966]">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={spaceContext}
|
||||
onChange={(e) => setSpaceContext(e.target.value)}
|
||||
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
|
||||
maxLength={750}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
|
||||
)}
|
||||
style={{
|
||||
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)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pl-1">
|
||||
<span className="text-[11px] text-[#737373]">Try a preset</span>
|
||||
{CONTEXT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setSpaceContext(preset.text)}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -247,7 +343,7 @@ export function AddSpaceModal({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] mr-1">+</span>
|
||||
<span className="mr-1 text-[16px] leading-none">+</span>
|
||||
Create Space
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
LifeBuoy,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -25,6 +24,7 @@ import {
|
|||
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
|
||||
const INTEGRATION_VIEWS: ViewMode[] = [
|
||||
"integrations",
|
||||
|
|
@ -43,7 +43,7 @@ interface BottomNavProps {
|
|||
}
|
||||
|
||||
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
||||
const router = useRouter()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
|
||||
|
||||
|
|
@ -55,11 +55,11 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
|||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
|
||||
"fixed inset-x-0 bottom-0 z-40 border-t border-white/[0.08] bg-[#0A0E14]/85 shadow-[0_-8px_24px_rgba(0,0,0,0.35)] backdrop-blur-xl md:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-around px-1 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<NavTab
|
||||
label="Home"
|
||||
icon={Home}
|
||||
|
|
@ -76,10 +76,10 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
|||
type="button"
|
||||
aria-label="Open chat"
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
|
||||
className="group relative flex size-11 shrink-0 items-center justify-center rounded-full shadow-[0_0_18px_rgba(75,160,250,0.35)] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60 active:scale-95"
|
||||
>
|
||||
<NovaOrb
|
||||
size={42}
|
||||
size={40}
|
||||
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -127,7 +127,7 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
|
|||
<MoreItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={() => router.push("/settings")}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -172,13 +172,13 @@ function NavTabButton({
|
|||
aria-current={active ? "page" : undefined}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
|
||||
"flex flex-1 flex-col items-center gap-1 rounded-lg py-1 outline-none transition-colors active:scale-95",
|
||||
active ? "text-white" : "text-[#737373] hover:text-white",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
<span className="text-[11px] font-medium leading-none">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
466
apps/web/components/brain-home/data.ts
Normal file
466
apps/web/components/brain-home/data.ts
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
export type Stat = {
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
deltaTint?: "up" | "down" | "neutral"
|
||||
}
|
||||
|
||||
export const STATS: Stat[] = [
|
||||
{
|
||||
label: "Items ingested",
|
||||
value: "2,272",
|
||||
delta: "+148 this week",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Agent sessions",
|
||||
value: "184",
|
||||
delta: "+42 vs last wk",
|
||||
deltaTint: "up",
|
||||
},
|
||||
{
|
||||
label: "Active contributors",
|
||||
value: "4",
|
||||
delta: "of 6 invited",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Top space",
|
||||
value: "Gmail",
|
||||
delta: "1,843 items",
|
||||
deltaTint: "neutral",
|
||||
},
|
||||
]
|
||||
|
||||
export type AgentSeries = { key: string; label: string; color: string }
|
||||
|
||||
export const AGENT_SERIES: AgentSeries[] = [
|
||||
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
|
||||
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
|
||||
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
|
||||
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
|
||||
{ key: "other", label: "Other", color: "#525D6E" },
|
||||
]
|
||||
|
||||
export const SESSIONS_BY_DAY: {
|
||||
label: string
|
||||
values: Record<string, number>
|
||||
}[] = [
|
||||
{
|
||||
label: "Mon",
|
||||
values: {
|
||||
cursor: 8,
|
||||
claudeCode: 4,
|
||||
claudeDesktop: 3,
|
||||
chatgpt: 2,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Tue",
|
||||
values: {
|
||||
cursor: 10,
|
||||
claudeCode: 6,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Wed",
|
||||
values: {
|
||||
cursor: 12,
|
||||
claudeCode: 9,
|
||||
claudeDesktop: 5,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Thu",
|
||||
values: {
|
||||
cursor: 9,
|
||||
claudeCode: 5,
|
||||
claudeDesktop: 4,
|
||||
chatgpt: 3,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Fri",
|
||||
values: {
|
||||
cursor: 14,
|
||||
claudeCode: 10,
|
||||
claudeDesktop: 6,
|
||||
chatgpt: 4,
|
||||
other: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sat",
|
||||
values: {
|
||||
cursor: 5,
|
||||
claudeCode: 3,
|
||||
claudeDesktop: 2,
|
||||
chatgpt: 1,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Sun",
|
||||
values: {
|
||||
cursor: 17,
|
||||
claudeCode: 12,
|
||||
claudeDesktop: 7,
|
||||
chatgpt: 4,
|
||||
other: 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const RECENT_ANSWERS = [
|
||||
{
|
||||
question: "What did we decide about pricing?",
|
||||
answer: "Pro stays at $20/mo. No overages, top-up only.",
|
||||
who: "You",
|
||||
when: "2h ago",
|
||||
},
|
||||
{
|
||||
question: "What's blocking the Acme deal?",
|
||||
answer: "MSA review — Jane drafting a net-30 counter.",
|
||||
who: "You",
|
||||
when: "4h ago",
|
||||
},
|
||||
{
|
||||
question: "Who owns the onboarding rewrite?",
|
||||
answer: "Design team. Ship target Friday.",
|
||||
who: "Sarah Kim",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
export const WEEKLY_DIGEST = {
|
||||
range: "Mon – Fri",
|
||||
headline:
|
||||
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
|
||||
bullets: [
|
||||
{ label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" },
|
||||
{
|
||||
label: "2 conflicts surfaced between Notion and Slack",
|
||||
tint: "text-[#FF8A47]",
|
||||
},
|
||||
{
|
||||
label: "Sarah Kim led contributions with 14 docs",
|
||||
tint: "text-[#A1A1AA]",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const TEAM_THIS_WEEK = [
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
email: "sarah@acme.com",
|
||||
contributions: 14,
|
||||
summary: "14 docs · 3 spaces",
|
||||
color: "#FF8A47",
|
||||
},
|
||||
{
|
||||
name: "Jane Doe",
|
||||
email: "jane@acme.com",
|
||||
contributions: 11,
|
||||
summary: "11 docs · 2 spaces",
|
||||
color: "#4BA0FA",
|
||||
},
|
||||
{
|
||||
name: "Mahesh S.",
|
||||
email: "mahesh@acme.com",
|
||||
contributions: 9,
|
||||
summary: "9 docs · 1 space",
|
||||
color: "#A1A1AA",
|
||||
},
|
||||
{
|
||||
name: "Alex Chen",
|
||||
email: "alex@acme.com",
|
||||
contributions: 7,
|
||||
summary: "7 docs · 2 spaces",
|
||||
color: "#10A37F",
|
||||
},
|
||||
]
|
||||
|
||||
export const TEAM_RECENT = [
|
||||
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
|
||||
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
|
||||
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
|
||||
{
|
||||
who: "Sarah Kim",
|
||||
what: "connected Notion to Sales space",
|
||||
when: "Yesterday",
|
||||
},
|
||||
]
|
||||
|
||||
export type ActivityEvent = {
|
||||
when: string
|
||||
text: string
|
||||
source: "drive" | "notion" | "slack" | "system" | "answer" | "decision"
|
||||
}
|
||||
|
||||
export const ACTIVITY: ActivityEvent[] = [
|
||||
{ when: "2m", text: "23 new docs ingested from Drive", source: "drive" },
|
||||
{ when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" },
|
||||
{
|
||||
when: "1h",
|
||||
text: "Decision surfaced: pricing locked at $20 Pro",
|
||||
source: "slack",
|
||||
},
|
||||
{
|
||||
when: "3h",
|
||||
text: "14 Notion pages updated by Sarah Kim",
|
||||
source: "notion",
|
||||
},
|
||||
{ when: "5h", text: "New entity formed: pricing-v3", source: "system" },
|
||||
{
|
||||
when: "Yesterday",
|
||||
text: "MSA contract uploaded · auto-tagged contracts/MSA",
|
||||
source: "drive",
|
||||
},
|
||||
]
|
||||
|
||||
export type TimelineKind =
|
||||
| "answer"
|
||||
| "decision"
|
||||
| "ingest"
|
||||
| "mention"
|
||||
| "system"
|
||||
|
||||
export type TimelineEvent = {
|
||||
kind: TimelineKind
|
||||
when: string
|
||||
title: string
|
||||
detail?: string
|
||||
source?: string
|
||||
who?: string
|
||||
}
|
||||
|
||||
export const TIMELINE_EVENTS: TimelineEvent[] = [
|
||||
{
|
||||
kind: "answer",
|
||||
when: "2m ago",
|
||||
title: "What did we decide about pricing?",
|
||||
detail: "Pro stays at $20/mo. No overages, top-up only.",
|
||||
source: "You asked",
|
||||
},
|
||||
{
|
||||
kind: "ingest",
|
||||
when: "14m ago",
|
||||
title: "23 new docs ingested from Drive",
|
||||
detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.",
|
||||
source: "Drive",
|
||||
},
|
||||
{
|
||||
kind: "decision",
|
||||
when: "1h ago",
|
||||
title: "Decision: pricing locked at $20 Pro",
|
||||
detail:
|
||||
"Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.",
|
||||
source: "Slack · #leadership",
|
||||
},
|
||||
{
|
||||
kind: "answer",
|
||||
when: "4h ago",
|
||||
title: "What's blocking the Acme deal?",
|
||||
detail: "MSA review — Jane drafting a net-30 counter.",
|
||||
source: "You asked",
|
||||
},
|
||||
{
|
||||
kind: "mention",
|
||||
when: "Today",
|
||||
title: "onboarding-rewrite mentioned in 3 docs",
|
||||
detail: "Owner: design team · Ship target: Friday.",
|
||||
source: "Drive",
|
||||
who: "Mahesh",
|
||||
},
|
||||
{
|
||||
kind: "system",
|
||||
when: "5h ago",
|
||||
title: "New entity formed: pricing-v3",
|
||||
detail: "Linked across 4 sources · auto-tagged by the brain.",
|
||||
},
|
||||
{
|
||||
kind: "ingest",
|
||||
when: "Yesterday",
|
||||
title: "MSA contract uploaded · auto-tagged contracts/MSA",
|
||||
source: "Drive",
|
||||
},
|
||||
{
|
||||
kind: "answer",
|
||||
when: "Yesterday",
|
||||
title: "Who owns the onboarding rewrite?",
|
||||
detail: "Design team. Ship target Friday.",
|
||||
source: "Sarah Kim asked",
|
||||
},
|
||||
{
|
||||
kind: "ingest",
|
||||
when: "Yesterday",
|
||||
title: "14 Notion pages updated by Sarah Kim",
|
||||
source: "Notion",
|
||||
who: "Sarah Kim",
|
||||
},
|
||||
]
|
||||
|
||||
export type SourceStatus = "healthy" | "syncing" | "warning" | "error"
|
||||
|
||||
export type ConnectedSource = {
|
||||
id: string
|
||||
name: string
|
||||
iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github"
|
||||
status: SourceStatus
|
||||
delta: string
|
||||
when: string
|
||||
statusMessage?: string
|
||||
}
|
||||
|
||||
export const CONNECTED_SOURCES: ConnectedSource[] = [
|
||||
{
|
||||
id: "drive",
|
||||
name: "Google Drive",
|
||||
iconKey: "drive",
|
||||
status: "healthy",
|
||||
delta: "+24 items",
|
||||
when: "12m ago",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Company Notion",
|
||||
iconKey: "notion",
|
||||
status: "warning",
|
||||
delta: "Sync stalled",
|
||||
when: "4h ago",
|
||||
statusMessage: "Re-auth · token expired",
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
iconKey: "gmail",
|
||||
status: "syncing",
|
||||
delta: "Ingesting…",
|
||||
when: "Just now",
|
||||
},
|
||||
]
|
||||
|
||||
export type RecommendedSource = {
|
||||
id: string
|
||||
name: string
|
||||
iconKey: "slack" | "linear" | "granola" | "github" | "calendar"
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const RECOMMENDED_SOURCES: RecommendedSource[] = [
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
iconKey: "slack",
|
||||
reason: "Decisions and threads — most teams connect this.",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
iconKey: "github",
|
||||
reason: "PRs, issues, READMEs from your repos.",
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
iconKey: "linear",
|
||||
reason: "Track project decisions and blockers.",
|
||||
},
|
||||
]
|
||||
|
||||
export type LiveSession = {
|
||||
name: string
|
||||
avatarColor: string
|
||||
status: "live" | "idle" | "away"
|
||||
tool: string
|
||||
toolColor: string
|
||||
doing?: string
|
||||
elapsed?: string
|
||||
}
|
||||
|
||||
export const LIVE_SESSIONS: LiveSession[] = [
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
avatarColor: "#FF8A47",
|
||||
status: "live",
|
||||
tool: "Cursor",
|
||||
toolColor: "#4BA0FA",
|
||||
doing: "Refactoring auth flow with brain context",
|
||||
elapsed: "3m elapsed",
|
||||
},
|
||||
{
|
||||
name: "Jane Doe",
|
||||
avatarColor: "#4BA0FA",
|
||||
status: "live",
|
||||
tool: "Claude Code",
|
||||
toolColor: "#FF8A47",
|
||||
doing: "MSA review · pulling Acme history",
|
||||
elapsed: "18m elapsed",
|
||||
},
|
||||
{
|
||||
name: "You",
|
||||
avatarColor: "#A1A1AA",
|
||||
status: "idle",
|
||||
tool: "Cursor",
|
||||
toolColor: "#4BA0FA",
|
||||
elapsed: "Idle · 12m",
|
||||
},
|
||||
]
|
||||
|
||||
export const TODAY_SESSIONS_SUMMARY: {
|
||||
name: string
|
||||
avatarColor: string
|
||||
tool: string
|
||||
count: number
|
||||
lastAt: string
|
||||
}[] = [
|
||||
{
|
||||
name: "Alex Chen",
|
||||
avatarColor: "#10A37F",
|
||||
tool: "ChatGPT",
|
||||
count: 4,
|
||||
lastAt: "2h ago",
|
||||
},
|
||||
{
|
||||
name: "Mahesh S.",
|
||||
avatarColor: "#A1A1AA",
|
||||
tool: "Claude Desktop",
|
||||
count: 7,
|
||||
lastAt: "3h ago",
|
||||
},
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
avatarColor: "#FF8A47",
|
||||
tool: "Cursor",
|
||||
count: 11,
|
||||
lastAt: "Now",
|
||||
},
|
||||
]
|
||||
|
||||
export type ChatMessage = { role: "user" | "brain"; text: string }
|
||||
|
||||
export const SAMPLE_CHAT: ChatMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
text: "What did we decide about pricing this quarter?",
|
||||
},
|
||||
{
|
||||
role: "brain",
|
||||
text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "Who's working on the Acme MSA?",
|
||||
},
|
||||
{
|
||||
role: "brain",
|
||||
text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).",
|
||||
},
|
||||
]
|
||||
671
apps/web/components/brain-home/widgets.tsx
Normal file
671
apps/web/components/brain-home/widgets.tsx
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Github,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Quote,
|
||||
Sparkles,
|
||||
Target,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
ACTIVITY,
|
||||
AGENT_SERIES,
|
||||
CONNECTED_SOURCES,
|
||||
RECENT_ANSWERS,
|
||||
RECOMMENDED_SOURCES,
|
||||
SESSIONS_BY_DAY,
|
||||
STATS,
|
||||
TEAM_RECENT,
|
||||
TEAM_THIS_WEEK,
|
||||
WEEKLY_DIGEST,
|
||||
type ActivityEvent,
|
||||
type ConnectedSource,
|
||||
type RecommendedSource,
|
||||
type Stat,
|
||||
} from "./data"
|
||||
|
||||
function GmailIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 256 193"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.668 33.95-4.668 41.685L128 145.504z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945z"
|
||||
fill="#FBBC04"
|
||||
/>
|
||||
<path
|
||||
d="m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23z"
|
||||
fill="#C5221F"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const modalCardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
|
||||
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)",
|
||||
}
|
||||
|
||||
export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="px-5 py-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{s.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold text-[#fafafa] mt-1.5 tabular-nums leading-none",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
{s.delta && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-medium mt-1.5",
|
||||
s.deltaTint === "up"
|
||||
? "text-[#4BA0FA]"
|
||||
: s.deltaTint === "down"
|
||||
? "text-[#FF8A47]"
|
||||
: "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{s.delta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, cta }: { title: string; cta?: string }) {
|
||||
return (
|
||||
<div className="flex items-end justify-between mb-3 px-1">
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
{title}
|
||||
</p>
|
||||
{cta && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecentAnswersCard() {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
Recent answers
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
|
||||
>
|
||||
See all →
|
||||
</button>
|
||||
</div>
|
||||
<ul className="space-y-2.5">
|
||||
{RECENT_ANSWERS.map((a) => (
|
||||
<li
|
||||
key={a.question}
|
||||
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
|
||||
>
|
||||
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
|
||||
{a.question}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
|
||||
→ {a.answer}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#737373] font-medium mt-1">
|
||||
{a.who} · {a.when}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeeklyDigestCard() {
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
|
||||
{WEEKLY_DIGEST.range}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold text-[#fafafa] leading-snug",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
|
||||
/^\d+$/.test(piece) ? (
|
||||
<span key={i} className="text-[#4BA0FA]">
|
||||
{piece}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{piece}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{WEEKLY_DIGEST.bullets.map((b) => (
|
||||
<li key={b.label} className="flex items-start gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
|
||||
/>
|
||||
<span className={cn("text-[12px] font-medium", b.tint)}>
|
||||
{b.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
|
||||
>
|
||||
Open weekly digest <ArrowRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrainPulseBlock() {
|
||||
return (
|
||||
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
|
||||
<RecentAnswersCard />
|
||||
<WeeklyDigestCard />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrainActivityChart() {
|
||||
const totalsByAgent = AGENT_SERIES.map((s) => ({
|
||||
...s,
|
||||
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
|
||||
}))
|
||||
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
|
||||
const dayTotals = SESSIONS_BY_DAY.map((d) =>
|
||||
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
|
||||
)
|
||||
const max = Math.max(...dayTotals)
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sessions by agent
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
|
||||
{grandTotal} agent sessions, last 7 days — which tool the team
|
||||
reached for.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
|
||||
{totalsByAgent.map((s) => (
|
||||
<span
|
||||
key={s.key}
|
||||
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: s.color }}
|
||||
/>
|
||||
{s.label}{" "}
|
||||
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
{SESSIONS_BY_DAY.map((d, dayIdx) => {
|
||||
const BAR_AREA_PX = 140
|
||||
const totalForDay = dayTotals[dayIdx] ?? 0
|
||||
const barHeightPx = Math.max(
|
||||
6,
|
||||
Math.round((totalForDay / max) * BAR_AREA_PX),
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={d.label}
|
||||
className="flex-1 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-full flex items-end"
|
||||
style={{ height: `${BAR_AREA_PX}px` }}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-[4px] overflow-hidden flex flex-col"
|
||||
style={{ height: `${barHeightPx}px` }}
|
||||
title={`${d.label}: ${totalForDay} sessions`}
|
||||
>
|
||||
{AGENT_SERIES.map((series, sIdx) => {
|
||||
const val = d.values[series.key] ?? 0
|
||||
if (val === 0) return null
|
||||
const segHeight =
|
||||
totalForDay > 0 ? (val / totalForDay) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
key={series.key}
|
||||
style={{
|
||||
height: `${segHeight}%`,
|
||||
background: series.color,
|
||||
}}
|
||||
className={cn(
|
||||
sIdx === 0 && "rounded-t-[4px]",
|
||||
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
|
||||
)}
|
||||
title={`${series.label} · ${val}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function activitySourceIcon(s: ActivityEvent["source"]) {
|
||||
if (s === "drive") return <GoogleDrive className="size-3.5" />
|
||||
if (s === "notion") return <Notion className="size-3.5" />
|
||||
if (s === "slack")
|
||||
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
|
||||
if (s === "answer") return <Sparkles className="size-3.5 text-[#4BA0FA]" />
|
||||
if (s === "decision")
|
||||
return <CheckCircle2 className="size-3.5 text-[#4BA0FA]" />
|
||||
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
|
||||
}
|
||||
|
||||
export function ActivityFeedBlock() {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Activity feed" cta="Show more →" />
|
||||
<div
|
||||
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{ACTIVITY.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
|
||||
{activitySourceIcon(a.source)}
|
||||
</div>
|
||||
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
|
||||
{a.text}
|
||||
</p>
|
||||
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
|
||||
{a.when}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TeamActivityRail() {
|
||||
return (
|
||||
<section
|
||||
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Team activity
|
||||
</p>
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
|
||||
This week
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#737373] font-medium">
|
||||
What your team's been adding to the brain.
|
||||
</p>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{TEAM_THIS_WEEK.map((member) => (
|
||||
<div
|
||||
key={member.email}
|
||||
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div
|
||||
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
|
||||
style={{ background: member.color }}
|
||||
>
|
||||
{member.name[0]}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] text-[#fafafa] font-medium truncate">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium truncate">
|
||||
{member.summary}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
|
||||
{member.contributions}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 pt-4 border-t border-white/[0.04]">
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
|
||||
Recent
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{TEAM_RECENT.map((event, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
|
||||
>
|
||||
<span className="text-[#fafafa]">{event.who}</span> {event.what}
|
||||
<span className="text-[#525D6E]"> · {event.when}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Invite teammates
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrainHomeBackground() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 60% at 50% 0%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
|
||||
<div
|
||||
id="brain-home-grid"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.18)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Quotation({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[#737373]">
|
||||
<Quote className="size-3" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function connectedSourceIcon(key: ConnectedSource["iconKey"]) {
|
||||
if (key === "drive") return <GoogleDrive className="size-4" />
|
||||
if (key === "notion") return <Notion className="size-4" />
|
||||
if (key === "gmail") return <GmailIcon className="size-4" />
|
||||
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
|
||||
return <FileText className="size-4 text-[#8B8B8B]" />
|
||||
}
|
||||
|
||||
function sourceStatusMeta(status: ConnectedSource["status"]) {
|
||||
if (status === "healthy")
|
||||
return {
|
||||
dot: "bg-[#4BA0FA]",
|
||||
tint: "text-[#4BA0FA]",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
}
|
||||
if (status === "syncing")
|
||||
return {
|
||||
dot: "bg-[#A1A1AA]",
|
||||
tint: "text-[#A1A1AA]",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
}
|
||||
if (status === "warning")
|
||||
return {
|
||||
dot: "bg-[#FF8A47]",
|
||||
tint: "text-[#FF8A47]",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
}
|
||||
return {
|
||||
dot: "bg-[#FF5C5C]",
|
||||
tint: "text-[#FF5C5C]",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
}
|
||||
}
|
||||
|
||||
export function SourcesHealthRail({ empty }: { empty?: boolean }) {
|
||||
if (empty) {
|
||||
return (
|
||||
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sources health
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
|
||||
Nothing connected yet — Drive, Notion, Gmail and more are ready when
|
||||
you are.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex items-center justify-center gap-1.5 h-8 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Connect a source
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const issueCount = CONNECTED_SOURCES.filter(
|
||||
(s) => s.status === "warning" || s.status === "error",
|
||||
).length
|
||||
return (
|
||||
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Sources health
|
||||
</p>
|
||||
{issueCount > 0 ? (
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] text-[#FF8A47] font-semibold">
|
||||
{issueCount} need attention
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] text-[#4BA0FA] font-semibold">
|
||||
All healthy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#737373] font-medium">
|
||||
Is the brain getting fed?
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-0.5">
|
||||
{CONNECTED_SOURCES.map((s) => {
|
||||
const meta = sourceStatusMeta(s.status)
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div className="size-6 flex items-center justify-center shrink-0">
|
||||
{connectedSourceIcon(s.iconKey)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-[12px] text-[#fafafa] font-medium truncate">
|
||||
{s.name}
|
||||
</p>
|
||||
<span className="text-[10px] text-[#737373] font-medium tabular-nums shrink-0">
|
||||
{s.when}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] font-medium mt-0.5 inline-flex items-center gap-1",
|
||||
meta.tint,
|
||||
)}
|
||||
>
|
||||
{meta.icon}
|
||||
<span className="truncate">{s.statusMessage ?? s.delta}</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function recommendedIcon(key: RecommendedSource["iconKey"]) {
|
||||
if (key === "slack")
|
||||
return <MessageSquare className="size-4 text-[#4BA0FA]" />
|
||||
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
|
||||
if (key === "linear") return <Target className="size-4 text-[#fafafa]" />
|
||||
if (key === "granola") return <Sparkles className="size-4 text-[#FF8A47]" />
|
||||
if (key === "calendar") return <Calendar className="size-4 text-[#A1A1AA]" />
|
||||
return <FileText className="size-4 text-[#8B8B8B]" />
|
||||
}
|
||||
|
||||
export function RecommendedRail() {
|
||||
return (
|
||||
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[14px] font-semibold leading-tight",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Add more to the brain
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] font-medium mt-0.5">
|
||||
Teams like yours connect these next.
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-0.5">
|
||||
{RECOMMENDED_SOURCES.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="group flex items-start gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="size-6 flex items-center justify-center shrink-0">
|
||||
{recommendedIcon(r.iconKey)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[12px] text-[#fafafa] font-medium truncate">
|
||||
{r.name}
|
||||
</p>
|
||||
<ArrowRight className="size-3 text-[#525D6E] group-hover:text-[#fafafa] transition-colors shrink-0" />
|
||||
</div>
|
||||
<p className="text-[11px] text-[#737373] font-medium leading-[1.4] mt-0.5 line-clamp-2">
|
||||
{r.reason}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
82
apps/web/components/chat/attachments.ts
Normal file
82
apps/web/components/chat/attachments.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export const CHAT_ATTACHMENT_ACCEPT =
|
||||
"image/*,.pdf,application/pdf,.doc,.docx,.txt,.md,.mdx,.markdown,text/markdown"
|
||||
|
||||
export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
|
||||
|
||||
const SUPPORTED_EXTENSIONS = new Set([
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
".txt",
|
||||
".md",
|
||||
".mdx",
|
||||
".markdown",
|
||||
])
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string
|
||||
documentId?: string
|
||||
filename: string
|
||||
mediaType: string
|
||||
size: number
|
||||
saveToMemory: boolean
|
||||
status: "ready" | "processing" | "failed"
|
||||
url?: string
|
||||
contentPreview?: string
|
||||
}
|
||||
|
||||
export type ChatAttachmentDraftStatus =
|
||||
| "queued"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "error"
|
||||
|
||||
export type ChatAttachmentDraft = {
|
||||
id: string
|
||||
file: File
|
||||
saveToMemory: boolean
|
||||
status: ChatAttachmentDraftStatus
|
||||
errorMessage?: string
|
||||
uploaded?: ChatAttachment
|
||||
}
|
||||
|
||||
export type ChatAttachmentMessageMetadata = {
|
||||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
export function isAcceptedChatAttachment(file: File): boolean {
|
||||
if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false
|
||||
const name = file.name.toLowerCase()
|
||||
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
|
||||
if (SUPPORTED_EXTENSIONS.has(ext)) return true
|
||||
if (file.type.startsWith("image/")) return true
|
||||
if (file.type === "application/pdf") return true
|
||||
if (file.type === "text/markdown") return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function chatAttachmentKey(file: File): string {
|
||||
return `${file.name}:${file.size}:${file.lastModified}`
|
||||
}
|
||||
|
||||
export function createChatAttachmentDraft(file: File): ChatAttachmentDraft {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
saveToMemory: true,
|
||||
status: "queued",
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAttachmentSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`
|
||||
const kb = size / 1024
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
||||
return `${(kb / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function getChatMessageAttachments(metadata: unknown): ChatAttachment[] {
|
||||
const attachments = (metadata as ChatAttachmentMessageMetadata | undefined)
|
||||
?.attachments
|
||||
return Array.isArray(attachments) ? attachments : []
|
||||
}
|
||||
|
|
@ -8,6 +8,14 @@ import { cn } from "@lib/utils"
|
|||
import type { ModelId } from "@/lib/models"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
chatAttachmentKey,
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
createChatAttachmentDraft,
|
||||
type ChatAttachmentDraft,
|
||||
isAcceptedChatAttachment,
|
||||
} from "./attachments"
|
||||
import { ReasoningSelector } from "./reasoning-selector"
|
||||
import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models"
|
||||
|
||||
|
|
@ -20,13 +28,17 @@ export function HomeChatComposer({
|
|||
model: ModelId,
|
||||
projectId: string,
|
||||
reasoningEffort: ReasoningEffort,
|
||||
attachments?: ChatAttachmentDraft[],
|
||||
) => void
|
||||
className?: string
|
||||
}) {
|
||||
const [input, setInput] = useState("")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
getDefaultReasoningEffort("gemini-2.5-pro"),
|
||||
getDefaultReasoningEffort("grok-4.3"),
|
||||
)
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
|
|
@ -40,15 +52,18 @@ export function HomeChatComposer({
|
|||
|
||||
const send = useCallback(() => {
|
||||
const t = input.trim()
|
||||
if (!t) return
|
||||
if (!t && attachmentDrafts.length === 0) return
|
||||
onStartChat(
|
||||
t,
|
||||
selectedModel,
|
||||
chatSpaceProjects[0] ?? selectedProject,
|
||||
reasoningEffort,
|
||||
attachmentDrafts,
|
||||
)
|
||||
setInput("")
|
||||
setAttachmentDrafts([])
|
||||
}, [
|
||||
attachmentDrafts,
|
||||
chatSpaceProjects,
|
||||
input,
|
||||
onStartChat,
|
||||
|
|
@ -57,6 +72,51 @@ export function HomeChatComposer({
|
|||
selectedProject,
|
||||
])
|
||||
|
||||
const handleAddAttachmentFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const incoming = Array.from(files)
|
||||
const accepted = incoming.filter(isAcceptedChatAttachment)
|
||||
const rejected = incoming.length - accepted.length
|
||||
if (rejected > 0) {
|
||||
toast.error(
|
||||
rejected === 1
|
||||
? "One attachment is not supported or is over 50MB"
|
||||
: `${rejected} attachments are not supported or are over 50MB`,
|
||||
)
|
||||
}
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const existingKeys = new Set(
|
||||
attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
|
||||
)
|
||||
const nextItems: ChatAttachmentDraft[] = []
|
||||
let duplicateCount = 0
|
||||
for (const file of accepted) {
|
||||
const key = chatAttachmentKey(file)
|
||||
if (existingKeys.has(key)) {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
existingKeys.add(key)
|
||||
nextItems.push(createChatAttachmentDraft(file))
|
||||
}
|
||||
if (duplicateCount > 0) {
|
||||
toast.message(
|
||||
duplicateCount === 1
|
||||
? "Skipped duplicate attachment"
|
||||
: `Skipped ${duplicateCount} duplicate attachments`,
|
||||
)
|
||||
}
|
||||
if (nextItems.length === 0) return
|
||||
setAttachmentDrafts((prev) => [...prev, ...nextItems])
|
||||
},
|
||||
[attachmentDrafts],
|
||||
)
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
|
|
@ -66,7 +126,7 @@ export function HomeChatComposer({
|
|||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6">
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-2 md:pb-6">
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
|
@ -74,27 +134,34 @@ export function HomeChatComposer({
|
|||
onStop={() => {}}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={false}
|
||||
attachments={attachmentDrafts}
|
||||
onAddAttachmentFiles={handleAddAttachmentFiles}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
canSend={input.trim().length > 0 || attachmentDrafts.length > 0}
|
||||
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
|
||||
showStatusStrip={false}
|
||||
stackedToolbar={
|
||||
<>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
|
||||
/>
|
||||
</>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
minimal
|
||||
/>
|
||||
}
|
||||
toolbarTrailing={
|
||||
<ReasoningSelector
|
||||
value={reasoningEffort}
|
||||
onChange={setReasoningEffort}
|
||||
/>
|
||||
}
|
||||
toolbarEnd={
|
||||
<SpaceSelector
|
||||
selectedProjects={chatSpaceProjects}
|
||||
onValueChange={setChatSpaceProjects}
|
||||
variant="insideOut"
|
||||
includeAuto
|
||||
hideCount
|
||||
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 shadow-none hover:bg-white/5"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react"
|
||||
import {
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
RotateCcwIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { SuperLoader } from "@/components/superloader"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
import {
|
||||
CHAT_ATTACHMENT_ACCEPT,
|
||||
type ChatAttachmentDraft,
|
||||
formatAttachmentSize,
|
||||
} from "../attachments"
|
||||
import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models"
|
||||
|
||||
export interface QueuedChatMessagePreview {
|
||||
|
|
@ -31,8 +47,19 @@ interface ChatInputProps {
|
|||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Model + space controls on one row with send; textarea full-width above */
|
||||
stackedToolbar?: ReactNode
|
||||
/** Trailing controls rendered after the attach button (e.g. reasoning) */
|
||||
toolbarTrailing?: ReactNode
|
||||
/** Controls rendered just left of the send button (e.g. space selector) */
|
||||
toolbarEnd?: ReactNode
|
||||
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
|
||||
showStatusStrip?: boolean
|
||||
attachments?: ChatAttachmentDraft[]
|
||||
onAddAttachmentFiles?: (files: FileList | File[]) => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onRetryAttachment?: (id: string) => void
|
||||
canSend?: boolean
|
||||
attachmentAccept?: string
|
||||
disableFileDropZone?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
|
|
@ -49,17 +76,28 @@ export default function ChatInput({
|
|||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
toolbarTrailing,
|
||||
toolbarEnd,
|
||||
showStatusStrip = true,
|
||||
attachments = [],
|
||||
onAddAttachmentFiles,
|
||||
onRemoveAttachment,
|
||||
onRetryAttachment,
|
||||
canSend,
|
||||
attachmentAccept = CHAT_ATTACHMENT_ACCEPT,
|
||||
disableFileDropZone = false,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isSendDisabled = !value.trim() || sendDisabled
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
const canSubmit = canSend ?? value.trim().length > 0
|
||||
const isSendDisabled = !canSubmit || sendDisabled
|
||||
const hasQueuedPreview = queuedMessages.length > 0
|
||||
const resolvedSendDisabledTooltip =
|
||||
sendDisabled && value.trim()
|
||||
? sendDisabledTooltip
|
||||
: "Type a message to send"
|
||||
sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send"
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStatusStrip && isExpanded) {
|
||||
|
|
@ -81,12 +119,110 @@ export default function ChatInput({
|
|||
setIsMultiline(textarea.scrollHeight > 52)
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files?.length) onAddAttachmentFiles?.(files)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding
|
||||
const hasDraggedFiles = (e: React.DragEvent) =>
|
||||
Array.from(e.dataTransfer.types).includes("Files")
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current += 1
|
||||
if (canAttachFiles) setIsDraggingFiles(true)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none"
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setIsDraggingFiles(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragDepthRef.current = 0
|
||||
setIsDraggingFiles(false)
|
||||
const files = e.dataTransfer.files
|
||||
if (canAttachFiles && files.length) onAddAttachmentFiles?.(files)
|
||||
}
|
||||
|
||||
const showAttachments = attachments.length > 0
|
||||
|
||||
const attachmentTray = showAttachments ? (
|
||||
<div className="scrollbar-none flex max-w-full gap-2 overflow-x-auto px-0 pb-1 sm:px-1">
|
||||
{attachments.map((attachment) => {
|
||||
return (
|
||||
<AttachmentPreviewChip
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onRemove={onRemoveAttachment}
|
||||
onRetry={onRetryAttachment}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const attachmentButton = onAddAttachmentFiles ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={attachmentAccept}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isResponding}
|
||||
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white/45 transition-colors hover:bg-white/5 hover:text-white/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Attach files"
|
||||
title="Attach files"
|
||||
>
|
||||
<PaperclipIcon className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
) : null
|
||||
|
||||
const dropOverlay =
|
||||
!disableFileDropZone && isDraggingFiles ? (
|
||||
<div className="pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-lg border border-dashed border-[#4B5563] bg-black/70 text-sm font-medium text-fg-primary backdrop-blur-sm">
|
||||
Drop files to attach
|
||||
</div>
|
||||
) : null
|
||||
const dropZoneProps = disableFileDropZone
|
||||
? {}
|
||||
: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("relative z-20!")}
|
||||
animate={{
|
||||
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
|
||||
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
|
||||
margin: "0",
|
||||
borderRadius: showStatusStrip
|
||||
? isExpanded
|
||||
? "0 0 12px 12px"
|
||||
|
|
@ -133,10 +269,16 @@ export default function ChatInput({
|
|||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
{isResponding ? (
|
||||
<SuperLoader label={activeStatus || "Thinking…"} />
|
||||
) : (
|
||||
<>
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
|
|
@ -195,7 +337,13 @@ export default function ChatInput({
|
|||
</>
|
||||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="relative z-30 flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
|
||||
<fieldset
|
||||
aria-label="Chat input with file drop zone"
|
||||
{...dropZoneProps}
|
||||
className="relative z-30 m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10"
|
||||
>
|
||||
{dropOverlay}
|
||||
{attachmentTray}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -207,12 +355,15 @@ export default function ChatInput({
|
|||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{stackedToolbar}
|
||||
{attachmentButton}
|
||||
{toolbarTrailing}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{toolbarEnd}
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
{(!isResponding || canSubmit) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
|
|
@ -221,14 +372,18 @@ export default function ChatInput({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
) : (
|
||||
<div
|
||||
<fieldset
|
||||
aria-label="Chat input with file drop zone"
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
|
||||
"relative m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
{dropOverlay}
|
||||
{attachmentTray}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
|
|
@ -239,9 +394,10 @@ export default function ChatInput({
|
|||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 transition-all duration-200">
|
||||
<div className="flex w-full items-center justify-end gap-2 transition-all duration-200">
|
||||
{attachmentButton}
|
||||
{isResponding && <StopButton onClick={onStop} />}
|
||||
{(!isResponding || value.trim()) && (
|
||||
{(!isResponding || canSubmit) && (
|
||||
<SendButton
|
||||
onClick={onSend}
|
||||
disabled={isSendDisabled}
|
||||
|
|
@ -249,8 +405,197 @@ export default function ChatInput({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPreviewChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
onRetry,
|
||||
}: {
|
||||
attachment: ChatAttachmentDraft
|
||||
onRemove?: (id: string) => void
|
||||
onRetry?: (id: string) => void
|
||||
}) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null)
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const isUploading = attachment.status === "uploading"
|
||||
const isUploaded = attachment.status === "uploaded"
|
||||
const isError = attachment.status === "error"
|
||||
const isImage = attachment.file.type.startsWith("image/")
|
||||
const extension = getAttachmentExtension(attachment.file)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) {
|
||||
setObjectUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(attachment.file)
|
||||
setObjectUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [attachment.file, isImage])
|
||||
|
||||
const statusLabel = isError
|
||||
? attachment.errorMessage || "Upload failed"
|
||||
: isUploading
|
||||
? "Uploading..."
|
||||
: isUploaded
|
||||
? "Uploaded"
|
||||
: formatAttachmentSize(attachment.file.size)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex h-11 w-[min(280px,calc(100vw-4.5rem))] shrink-0 items-center gap-2 overflow-hidden rounded-xl border border-[#1A1D22] bg-[#050607] px-2 text-sm text-fg-primary shadow-[0_6px_18px_rgba(0,0,0,0.22)] transition-colors hover:border-[#30343B] hover:bg-[#080A0D] focus-within:border-[#30343B] sm:w-auto sm:min-w-[220px] sm:max-w-[300px] sm:hover:border-[#2261CA66] sm:hover:bg-[#041127] sm:focus-within:border-[#2261CA66] sm:focus-within:bg-[#041127]",
|
||||
isImage && objectUrl && "cursor-pointer",
|
||||
isError && "border-red-400/40 bg-red-950/20 hover:border-red-400/50",
|
||||
)}
|
||||
title={statusLabel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isImage && objectUrl && setIsPreviewOpen(true)}
|
||||
disabled={!isImage || !objectUrl}
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#242832] bg-black",
|
||||
isImage && objectUrl && "cursor-pointer hover:border-[#4B5563]",
|
||||
(!isImage || !objectUrl) && "cursor-default",
|
||||
)}
|
||||
aria-label={
|
||||
isImage ? `Preview ${attachment.file.name}` : attachment.file.name
|
||||
}
|
||||
>
|
||||
{isImage && objectUrl ? (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<DocumentFileGlyph label={extension} />
|
||||
)}
|
||||
{isUploading || isUploaded ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
{isUploading ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin text-[#C8D1DC]" />
|
||||
) : (
|
||||
<CheckIcon className="size-3.5 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 pr-1">
|
||||
<div
|
||||
className="truncate font-medium leading-none text-fg-primary"
|
||||
title={attachment.file.name}
|
||||
>
|
||||
{attachment.file.name}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] leading-none text-fg-faint">
|
||||
{statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-1 opacity-0 transition-opacity sm:flex sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
|
||||
{isError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRetry?.(attachment.id)
|
||||
}}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-[#242832] bg-black text-fg-faint transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-fg-primary"
|
||||
aria-label={`Retry ${attachment.file.name}`}
|
||||
title={statusLabel}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemove?.(attachment.id)
|
||||
}}
|
||||
disabled={isUploading}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[calc(100vw-32px)] max-w-none gap-0 overflow-hidden rounded-xl border border-[#1D222A] bg-[#050607] p-0 text-fg-primary shadow-[0_24px_80px_rgba(0,0,0,0.65)] sm:w-[min(92vw,980px)] sm:max-w-[980px]"
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Preview {attachment.file.name}
|
||||
</DialogTitle>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_32px] items-center gap-2 border-[#171B22] border-b px-3 py-2 sm:px-4 sm:py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-fg-primary text-sm">
|
||||
{attachment.file.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-fg-faint">
|
||||
{formatAttachmentSize(attachment.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(false)}
|
||||
className="flex size-8 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary focus:outline-none focus:ring-2 focus:ring-[#3374FF]/40"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close preview</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid h-[min(58dvh,380px)] place-items-center bg-black px-4 py-5 sm:h-[min(76dvh,680px)] sm:px-6 sm:py-6">
|
||||
{objectUrl ? (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={attachment.file.name}
|
||||
className="block max-h-full max-w-full rounded-lg object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentFileGlyph({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="relative flex size-6 items-end justify-center rounded-[4px] border border-[#30343B] bg-[#07090C] pb-1">
|
||||
<div className="absolute right-0 top-0 size-2.5 border-[#30343B] border-b border-l bg-black" />
|
||||
<span className="max-w-[22px] truncate text-[8px] font-bold uppercase leading-none text-[#AAB2BD]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAttachmentExtension(file: File): string {
|
||||
const name = file.name
|
||||
const dotIndex = name.lastIndexOf(".")
|
||||
if (dotIndex > -1 && dotIndex < name.length - 1) {
|
||||
const extension = name.slice(dotIndex + 1).toLowerCase()
|
||||
if (extension === "md" || extension === "markdown") return "MD"
|
||||
if (extension === "pdf") return "PDF"
|
||||
return extension.slice(0, 3)
|
||||
}
|
||||
if (file.type === "text/markdown") return "MD"
|
||||
if (file.type.includes("pdf")) return "PDF"
|
||||
if (file.type.startsWith("text/")) return "TXT"
|
||||
return "FILE"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import {
|
||||
|
|
@ -52,48 +52,205 @@ type SourceUrlPart = {
|
|||
title?: string
|
||||
}
|
||||
|
||||
function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
|
||||
function sourceHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "")
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(host: string): string {
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
|
||||
}
|
||||
|
||||
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return isWebSearchToolName(part.toolName ?? "")
|
||||
}
|
||||
if (part.type.startsWith("tool-")) {
|
||||
return isWebSearchToolName(part.type.slice("tool-".length))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function CitationLink({
|
||||
href,
|
||||
label,
|
||||
source,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
source?: SourceUrlPart
|
||||
}) {
|
||||
const url = source?.url ?? href
|
||||
const host = sourceHost(url)
|
||||
const rawTitle = source?.title?.trim()
|
||||
const hasTitle =
|
||||
!!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle)
|
||||
let path = ""
|
||||
try {
|
||||
path = new URL(url).pathname.replace(/\/$/, "")
|
||||
} catch {}
|
||||
return (
|
||||
<span className="group/cite relative inline">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 no-underline hover:text-blue-300"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden -translate-x-1/2 pb-1.5 group-hover/cite:block">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pointer-events-auto block w-64 overflow-hidden rounded-lg border border-white/10 bg-[#0B0F16]/95 p-2.5 no-underline shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="size-4 shrink-0 rounded bg-white/10"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="truncate text-xs font-medium text-white/85">
|
||||
{host}
|
||||
</span>
|
||||
</span>
|
||||
{hasTitle ? (
|
||||
<span className="mt-1.5 block line-clamp-2 text-xs leading-snug text-white/60">
|
||||
{rawTitle}
|
||||
</span>
|
||||
) : path ? (
|
||||
<span className="mt-1 block truncate text-[11px] text-white/35">
|
||||
{path}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function makeMarkdownComponents(sources: SourceUrlPart[]) {
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
||||
const label =
|
||||
typeof children === "string"
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join("")
|
||||
: ""
|
||||
const match = label.match(/^\[?(\d+)\]?$/)
|
||||
if (match && href) {
|
||||
const n = Number(match[1])
|
||||
const source = sources.find((s) => s.url === href) ?? sources[n - 1]
|
||||
return <CitationLink href={href} label={label} source={source} />
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown)
|
||||
return () => document.removeEventListener("mousedown", onDown)
|
||||
}, [expanded])
|
||||
|
||||
if (sources.length === 0) return null
|
||||
|
||||
const faviconHosts: string[] = []
|
||||
for (const s of sources) {
|
||||
const host = sourceHost(s.url)
|
||||
if (!faviconHosts.includes(host)) faviconHosts.push(host)
|
||||
if (faviconHosts.length >= 3) break
|
||||
}
|
||||
const count = sources.length
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
|
||||
expanded && "border-b border-[#1E2128]",
|
||||
)}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${count} web ${count === 1 ? "source" : "sources"}`}
|
||||
>
|
||||
<GlobeIcon className="size-3 shrink-0 text-emerald-400" />
|
||||
<span className="font-medium text-emerald-400">
|
||||
Web sources
|
||||
<span className="text-white/40 font-normal ml-1">
|
||||
({sources.length})
|
||||
</span>
|
||||
<span className="flex -space-x-1.5">
|
||||
{faviconHosts.length > 0 ? (
|
||||
faviconHosts.map((host) => (
|
||||
<img
|
||||
key={host}
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="size-4 rounded-full border border-[#0B0F16] bg-white/10 object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5 text-emerald-400" />
|
||||
)}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3 text-white/30 shrink-0 ml-auto" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3 text-white/30 shrink-0 ml-auto" />
|
||||
)}
|
||||
<span>{count === 1 ? "1 source" : `${count} sources`}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<ul className="px-3 py-2 space-y-1.5 list-none">
|
||||
{sources.map((s) => (
|
||||
<li key={s.sourceId}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{s.title?.trim() || s.url}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
|
||||
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
|
||||
{sources.map((s) => {
|
||||
const host = sourceHost(s.url)
|
||||
return (
|
||||
<li key={s.sourceId}>
|
||||
<a
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
|
||||
>
|
||||
<img
|
||||
src={faviconUrl(host)}
|
||||
alt=""
|
||||
className="mt-0.5 size-4 shrink-0 rounded-full bg-white/10"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-white/80">
|
||||
{s.title?.trim() || host}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-white/40">
|
||||
{host}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -210,6 +367,23 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
|
|||
if (toolName === "bash") {
|
||||
return <BashToolDisplay part={part} />
|
||||
}
|
||||
if (isWebSearchToolName(toolName)) {
|
||||
if (part.state === "output-available") return null
|
||||
if (part.state === "error" || part.state === "output-error") {
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 text-xs text-red-400/80">
|
||||
<GlobeIcon className="size-3.5 shrink-0" />
|
||||
<span>Web search failed</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 text-xs text-white/50">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400/80" />
|
||||
<span>Searching the web…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const meta =
|
||||
TOOL_META[toolName] ??
|
||||
(isWebSearchToolName(toolName)
|
||||
|
|
@ -342,6 +516,27 @@ export function AgentMessage({
|
|||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
const webSources = (() => {
|
||||
const seen = new Set<string>()
|
||||
const out: SourceUrlPart[] = []
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "source-url") continue
|
||||
const source = part as SourceUrlPart
|
||||
if (seen.has(source.url)) continue
|
||||
seen.add(source.url)
|
||||
out.push(source)
|
||||
}
|
||||
return out
|
||||
})()
|
||||
const hasAssistantText = message.parts.some(
|
||||
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
|
||||
)
|
||||
const sourceKey = webSources.map((s) => s.url).join("|")
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by stable source urls
|
||||
const markdownComponents = useMemo(
|
||||
() => makeMarkdownComponents(webSources),
|
||||
[sourceKey],
|
||||
)
|
||||
const responseModelLabel = responseModel
|
||||
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
|
||||
: null
|
||||
|
|
@ -358,24 +553,10 @@ export function AgentMessage({
|
|||
|
||||
{message.parts.map((part, partIndex) => {
|
||||
if (part.type === "source-url") {
|
||||
if (
|
||||
partIndex > 0 &&
|
||||
message.parts[partIndex - 1]?.type === "source-url"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const sources: SourceUrlPart[] = []
|
||||
for (let j = partIndex; j < message.parts.length; j++) {
|
||||
const p = message.parts[j]
|
||||
if (!p || p.type !== "source-url") break
|
||||
sources.push(p as SourceUrlPart)
|
||||
}
|
||||
return (
|
||||
<WebSourcesGroup
|
||||
key={`${message.id}-web-sources-${partIndex}`}
|
||||
sources={sources}
|
||||
/>
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (isWebSearchPart(part)) {
|
||||
return null
|
||||
}
|
||||
if (part.type === "source-document") {
|
||||
const doc = part as {
|
||||
|
|
@ -400,12 +581,30 @@ export function AgentMessage({
|
|||
)
|
||||
}
|
||||
if (part.type === "text") {
|
||||
// Skip fragments mid-run — source-url citations split one answer into
|
||||
// many text parts; rendering each separately tears markdown (lists etc.).
|
||||
let prev = partIndex - 1
|
||||
while (prev >= 0 && message.parts[prev]?.type === "source-url") {
|
||||
prev--
|
||||
}
|
||||
if (prev >= 0 && message.parts[prev]?.type === "text") {
|
||||
return null
|
||||
}
|
||||
let runText = ""
|
||||
for (let j = partIndex; j < message.parts.length; j++) {
|
||||
const p = message.parts[j]
|
||||
if (p?.type === "text") runText += p.text
|
||||
else if (p?.type === "source-url") continue
|
||||
else break
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${message.id}-${partIndex}`}
|
||||
className="text-sm text-white/90 chat-markdown-content"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
<Streamdown components={markdownComponents}>
|
||||
{runText}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -448,29 +647,41 @@ export function AgentMessage({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{responseModelLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAssistantText && (
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<MessageActions
|
||||
messageId={message.id}
|
||||
messageText={messageText}
|
||||
isLastMessage={isLastAgentMessage}
|
||||
isHovered={isHovered}
|
||||
copiedMessageId={copiedMessageId}
|
||||
messageFeedback={messageFeedback}
|
||||
onCopy={onCopy}
|
||||
onLike={onLike}
|
||||
onDislike={onDislike}
|
||||
/>
|
||||
{webSources.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-opacity duration-200",
|
||||
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<WebSourcesPill sources={webSources} />
|
||||
</div>
|
||||
)}
|
||||
{responseModelLabel && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{responseModelLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
|
||||
import { Check, Copy, FileIcon, PencilIcon, PencilOffIcon } from "lucide-react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import ChatModelSelector from "../model-selector"
|
||||
import { ReasoningSelector } from "../reasoning-selector"
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type ModelId,
|
||||
type ReasoningEffort,
|
||||
} from "@/lib/models"
|
||||
import { formatAttachmentSize, getChatMessageAttachments } from "../attachments"
|
||||
|
||||
interface UserMessageProps {
|
||||
message: UIMessage
|
||||
|
|
@ -45,6 +46,7 @@ export const UserMessage = memo(function UserMessage({
|
|||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
const attachments = getChatMessageAttachments(message.metadata)
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(text)
|
||||
|
|
@ -71,7 +73,31 @@ export const UserMessage = memo(function UserMessage({
|
|||
}, [isEditing])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end w-full">
|
||||
<div className="flex w-full flex-col items-end">
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-2 flex w-full max-w-[80%] flex-col items-end gap-1.5">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex max-w-full min-w-0 items-center gap-2 rounded-lg border border-[#303949] bg-[#0D121A]/80 px-2.5 py-2 text-left shadow-[0_6px_18px_rgba(0,0,0,0.18)]"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#111A27]">
|
||||
<FileIcon className="size-3.5 text-[#8FC3FF]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium text-white">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-white/50">
|
||||
{formatAttachmentSize(attachment.size)}
|
||||
{" | "}
|
||||
{attachment.saveToMemory ? "Saved" : "Chat only"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
|
|
@ -126,7 +152,9 @@ export const UserMessage = memo(function UserMessage({
|
|||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
|
||||
>
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
{text ? (
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
|
@ -44,8 +45,7 @@ export default function ChatModelSelector({
|
|||
const selectedModel = selectedModelProp ?? internalModel
|
||||
const currentModelData = modelNames[selectedModel]
|
||||
const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
|
||||
const selectedItemClass =
|
||||
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
|
||||
const selectedItemClass = "bg-white/[0.06] text-white"
|
||||
|
||||
const handleModelSelect = (modelId: ModelId) => {
|
||||
if (onModelChange) {
|
||||
|
|
@ -61,18 +61,23 @@ export default function ChatModelSelector({
|
|||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-white/15 bg-black px-3 py-1.5 text-[13px] text-white transition-colors hover:border-white/30 hover:bg-white/5",
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-white/80 transition-colors hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Model: ${selectedModelLabel}`}
|
||||
>
|
||||
<p className="min-w-0 truncate text-left text-white">
|
||||
<p className="min-w-0 truncate text-left text-white/80">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-white/55">{currentModelData.version}</span>
|
||||
<span className="text-white/40">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-white/55" />
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-white/40 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -89,7 +94,12 @@ export default function ChatModelSelector({
|
|||
{currentModelData.name}{" "}
|
||||
<span className="text-white/55">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-white/55" />
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-white/55 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
|
||||
|
|
@ -103,66 +113,82 @@ export default function ChatModelSelector({
|
|||
>
|
||||
{trigger}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
|
||||
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
const isSelected = selectedModel === model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: dropdownDirection === "up" ? 6 : -6,
|
||||
}}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
y: dropdownDirection === "up" ? 6 : -6,
|
||||
}}
|
||||
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl",
|
||||
dropdownDirection === "up"
|
||||
? "bottom-full mb-2 origin-bottom"
|
||||
: "top-full mt-2 origin-top",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
const isSelected = selectedModel === model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/[0.06]",
|
||||
)}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && handleModelSelect(model.id)
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/55",
|
||||
"truncate text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
</span>
|
||||
{modelData.name}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/40",
|
||||
)}
|
||||
>
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-white/50" : "text-white/40",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 truncate text-xs",
|
||||
isSelected ? "text-white/60" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
MoreHorizontalIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { BrainIcon, ZapIcon } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
|
||||
|
|
@ -20,124 +14,60 @@ interface ReasoningSelectorProps {
|
|||
dropdownDirection?: "up" | "down"
|
||||
}
|
||||
|
||||
const iconVariants = {
|
||||
initial: (toThinking: boolean) => ({
|
||||
y: toThinking ? "-110%" : "110%",
|
||||
opacity: 0,
|
||||
}),
|
||||
animate: { y: "0%", opacity: 1 },
|
||||
exit: (toThinking: boolean) => ({
|
||||
y: toThinking ? "110%" : "-110%",
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
export function ReasoningSelector({
|
||||
value,
|
||||
onChange,
|
||||
variant = "pill",
|
||||
disabled = false,
|
||||
dropdownDirection = "up",
|
||||
}: ReasoningSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const selected = reasoningOptions.find((option) => option.id === value)
|
||||
const SelectedIcon = value === "thinking" ? BrainIcon : ZapIcon
|
||||
const selectedLabel = selected?.label ?? "Reasoning"
|
||||
const selectedItemClass =
|
||||
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelect = (next: ReasoningEffort) => {
|
||||
onChange(next)
|
||||
setIsOpen(false)
|
||||
}
|
||||
const toThinking = value === "thinking"
|
||||
const Icon = toThinking ? BrainIcon : ZapIcon
|
||||
const selectedLabel =
|
||||
reasoningOptions.find((option) => option.id === value)?.label ?? "Reasoning"
|
||||
const nextValue: ReasoningEffort = toThinking ? "instant" : "thinking"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(nextValue)}
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-center",
|
||||
isOpen ? "z-[1000]" : "z-10",
|
||||
"group flex shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 text-xs text-white/80 transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={`Reasoning: ${selectedLabel} — click to switch`}
|
||||
aria-label={`Reasoning: ${selectedLabel}. Click to switch.`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "icon"
|
||||
? "rounded p-1.5 hover:bg-white/10"
|
||||
: "flex size-9 items-center justify-center gap-1.5 rounded-full border border-white/15 bg-black px-0 py-1.5 text-xs text-white hover:border-white/30 hover:bg-white/5 sm:size-auto sm:justify-start sm:px-2.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={`Reasoning: ${selectedLabel}`}
|
||||
aria-label={`Reasoning: ${selectedLabel}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{variant === "icon" ? (
|
||||
<MoreHorizontalIcon className="size-3.5 text-white/50 hover:text-white/80" />
|
||||
) : (
|
||||
<>
|
||||
<SelectedIcon className="size-3.5 shrink-0 text-white/65" />
|
||||
<span className="hidden text-white sm:inline">
|
||||
{selected?.label}
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-3.5 shrink-0 text-white/55 sm:block" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"isolate absolute left-0 z-[1000] w-[min(14rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
|
||||
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{reasoningOptions.map((option) => {
|
||||
const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
|
||||
const isSelected = option.id === value
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.id)}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? selectedItemClass
|
||||
: "text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isSelected ? "text-[#8DBDFF]" : "text-white/65",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isSelected ? "text-white" : "text-white",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="relative flex size-3.5 shrink-0 items-center justify-center overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout" custom={toThinking}>
|
||||
<motion.span
|
||||
key={value}
|
||||
custom={toThinking}
|
||||
variants={iconVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Icon className="size-3.5 text-white/45 transition-colors group-hover:text-white/70" />
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<span className="ml-0 max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:ml-1 group-hover:max-w-[88px] group-hover:opacity-100">
|
||||
{selectedLabel}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
type Profession,
|
||||
} from "@/hooks/use-personalization"
|
||||
import { normalizePluginClientId } from "@/lib/plugin-catalog"
|
||||
import { detectPluginSpace } from "@/lib/plugin-space"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -443,6 +444,23 @@ function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
function getPluginClientFromSpace(
|
||||
document: DocumentWithMemories,
|
||||
): string | null {
|
||||
const containerTags =
|
||||
(document as { containerTags?: string[] }).containerTags ?? []
|
||||
const memorySpaceTags = (document.memoryEntries ?? [])
|
||||
.map((entry) => entry.spaceContainerTag)
|
||||
.filter((tag): tag is string => !!tag)
|
||||
|
||||
for (const tag of [...containerTags, ...memorySpaceTags]) {
|
||||
const plugin = detectPluginSpace(tag)
|
||||
if (plugin) return normalizePluginClientId(plugin.pluginId)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getPluginClientFromDocument(
|
||||
document: DocumentWithMemories,
|
||||
): string | null {
|
||||
|
|
@ -462,6 +480,9 @@ function getPluginClientFromDocument(
|
|||
|
||||
if (hasClaudeCodeContainer(document)) return "claude_code"
|
||||
|
||||
const pluginClientFromSpace = getPluginClientFromSpace(document)
|
||||
if (pluginClientFromSpace) return pluginClientFromSpace
|
||||
|
||||
const content = getDocumentText(document)
|
||||
const title = document.title ?? ""
|
||||
if (
|
||||
|
|
@ -689,6 +710,26 @@ function parseToolUsage(
|
|||
}
|
||||
|
||||
// Attach latest document info to plugin items where available from MCP documents
|
||||
for (const [pluginId, docInfo] of latestDocPerPlugin) {
|
||||
const itemKey = `plugin_${pluginId}`
|
||||
if (toolMap.has(itemKey)) continue
|
||||
|
||||
const catalog = PLUGIN_DISPLAY_CATALOG[pluginId]
|
||||
toolMap.set(itemKey, {
|
||||
id: itemKey,
|
||||
name: catalog?.name ?? docInfo.title,
|
||||
type: "Plugin",
|
||||
icon: catalog?.icon ?? null,
|
||||
lastUsedAt: docInfo.at,
|
||||
hasBeenUsed: true,
|
||||
connectedAt: null,
|
||||
lastDocumentTitle: null,
|
||||
lastDocumentId: null,
|
||||
lastDocumentPreview: null,
|
||||
lastDocument: null,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [, item] of toolMap) {
|
||||
if (item.type === "Plugin" && !item.lastDocumentTitle) {
|
||||
const pluginId = item.id.replace(/^plugin_/, "")
|
||||
|
|
|
|||
|
|
@ -11,36 +11,37 @@ type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
|||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
function getFileTypeInfo(document: DocumentWithMemories): {
|
||||
fileName?: string
|
||||
extension: string
|
||||
color?: string
|
||||
} {
|
||||
const type = document.type?.toLowerCase()
|
||||
const mimeType = document.metadata?.mimeType as string | undefined
|
||||
const mimeType = (document.metadata?.mimeType ??
|
||||
document.metadata?.sm_internal_fileType) as string | undefined
|
||||
const fileName = (document.metadata?.sm_internal_fileName ??
|
||||
document.metadata?.fileName) as string | undefined
|
||||
const nameExt = fileName?.includes(".")
|
||||
? fileName.slice(fileName.lastIndexOf(".")).toLowerCase()
|
||||
: undefined
|
||||
|
||||
if (mimeType) {
|
||||
if (mimeType === "application/pdf") {
|
||||
return { extension: ".pdf", color: "#FF7673" }
|
||||
}
|
||||
if (mimeType.startsWith("image/")) {
|
||||
const ext = mimeType.split("/")[1] || "jpg"
|
||||
return { extension: `.${ext}` }
|
||||
}
|
||||
if (mimeType.startsWith("video/")) {
|
||||
const ext = mimeType.split("/")[1] || "mp4"
|
||||
return { extension: `.${ext}` }
|
||||
}
|
||||
if (nameExt === ".pdf" || mimeType === "application/pdf" || type === "pdf") {
|
||||
return { fileName, extension: ".pdf", color: "#FF7673" }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "pdf":
|
||||
return { extension: ".pdf", color: "#FF7673" }
|
||||
case "image":
|
||||
return { extension: ".jpg" }
|
||||
case "video":
|
||||
return { extension: ".mp4" }
|
||||
default:
|
||||
return { extension: ".file" }
|
||||
if (mimeType?.startsWith("image/") || type === "image") {
|
||||
const ext = nameExt || `.${mimeType?.split("/")[1] || "jpg"}`
|
||||
return { fileName, extension: ext }
|
||||
}
|
||||
if (mimeType?.startsWith("video/") || type === "video") {
|
||||
const ext = nameExt || `.${mimeType?.split("/")[1] || "mp4"}`
|
||||
return { fileName, extension: ext }
|
||||
}
|
||||
if (nameExt === ".html" || nameExt === ".htm" || mimeType === "text/html") {
|
||||
return { fileName, extension: nameExt || ".html", color: "#FF8A4C" }
|
||||
}
|
||||
if (nameExt) {
|
||||
return { fileName, extension: nameExt }
|
||||
}
|
||||
return { fileName, extension: ".file" }
|
||||
}
|
||||
|
||||
export const FilePreview = memo(function FilePreview({
|
||||
|
|
@ -50,10 +51,11 @@ export const FilePreview = memo(function FilePreview({
|
|||
}) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const { extension, color } = getFileTypeInfo(document)
|
||||
const { fileName, extension, color } = getFileTypeInfo(document)
|
||||
|
||||
const type = document.type?.toLowerCase()
|
||||
const mimeType = document.metadata?.mimeType as string | undefined
|
||||
const mimeType = (document.metadata?.mimeType ??
|
||||
document.metadata?.sm_internal_fileType) as string | undefined
|
||||
const isImage =
|
||||
(mimeType?.startsWith("image/") || type === "image") &&
|
||||
document.url &&
|
||||
|
|
@ -102,17 +104,23 @@ export const FilePreview = memo(function FilePreview({
|
|||
</div>
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<div className="flex items-center gap-1 mb-2 min-w-0">
|
||||
<DocumentIcon
|
||||
type={document.type}
|
||||
url={document.url}
|
||||
className="size-4"
|
||||
fileName={fileName}
|
||||
mimeType={mimeType}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-semibold truncate",
|
||||
)}
|
||||
style={{ color: color }}
|
||||
title={fileName}
|
||||
>
|
||||
{extension}
|
||||
{fileName || extension}
|
||||
</p>
|
||||
</div>
|
||||
{document.content && (
|
||||
|
|
|
|||
|
|
@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<p className="text-[11px] text-[#737373] truncate">
|
||||
{parsed.formatLabel}
|
||||
</p>
|
||||
</div>
|
||||
{parsed.identifierValue && (
|
||||
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
|
||||
{parsed.identifierValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
NotionDoc,
|
||||
PDF,
|
||||
} from "@ui/assets/icons"
|
||||
import { Globe, FileText, Image } from "lucide-react"
|
||||
import { Globe, FileText, FileCode, Image } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
function MCPIcon({ className }: { className?: string }) {
|
||||
|
|
@ -144,13 +144,60 @@ export interface DocumentIconProps {
|
|||
type: string | null | undefined
|
||||
source?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
mimeType?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
function fileExtensionIcon(
|
||||
ext: string | undefined,
|
||||
mimeType: string | null | undefined,
|
||||
iconClassName: string,
|
||||
): React.ReactNode | null {
|
||||
if (ext === ".html" || ext === ".htm" || mimeType === "text/html") {
|
||||
return <FileCode className={iconClassName} style={{ color: "#FF8A4C" }} />
|
||||
}
|
||||
switch (ext) {
|
||||
case ".pdf":
|
||||
return <PDF className={iconClassName} />
|
||||
case ".doc":
|
||||
case ".docx":
|
||||
return (
|
||||
<span style={{ color: BRAND_COLORS.word }}>
|
||||
<MicrosoftWord className={iconClassName} />
|
||||
</span>
|
||||
)
|
||||
case ".xls":
|
||||
case ".xlsx":
|
||||
case ".csv":
|
||||
return (
|
||||
<span style={{ color: BRAND_COLORS.excel }}>
|
||||
<MicrosoftExcel className={iconClassName} />
|
||||
</span>
|
||||
)
|
||||
case ".ppt":
|
||||
case ".pptx":
|
||||
return (
|
||||
<span style={{ color: BRAND_COLORS.powerpoint }}>
|
||||
<MicrosoftPowerpoint className={iconClassName} />
|
||||
</span>
|
||||
)
|
||||
case ".md":
|
||||
case ".mdx":
|
||||
case ".txt":
|
||||
case ".json":
|
||||
return <TextDocumentIcon className={iconClassName} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function DocumentIcon({
|
||||
type,
|
||||
source,
|
||||
url,
|
||||
fileName,
|
||||
mimeType,
|
||||
className,
|
||||
}: DocumentIconProps) {
|
||||
const iconClassName = cn("size-4", className)
|
||||
|
|
@ -163,6 +210,22 @@ export function DocumentIcon({
|
|||
return <YouTubeIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
// Uploaded files get a type icon, never the URL favicon of their storage host
|
||||
if (fileName || mimeType) {
|
||||
const lower = fileName?.toLowerCase()
|
||||
const ext = lower?.includes(".")
|
||||
? lower.slice(lower.lastIndexOf("."))
|
||||
: undefined
|
||||
const fileIcon = fileExtensionIcon(ext, mimeType, iconClassName)
|
||||
if (fileIcon) return fileIcon
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
return <Image className={iconClassName} style={{ color: "#FAFAFA" }} />
|
||||
}
|
||||
if (!type || type === "unknown" || type === "text") {
|
||||
return <FileText className={iconClassName} style={{ color: "#FAFAFA" }} />
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
type === "webpage" ||
|
||||
type === "url" ||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,60 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { getCachedFileBlob } from "@/lib/file-cache"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
title?: string | null
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function ImagePreview({ url, title }: ImagePreviewProps) {
|
||||
export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [activeSrc, setActiveSrc] = useState(url)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On first failure, wait briefly then force a re-render with a new key to
|
||||
// retry the fetch (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const handleImageError = useCallback(() => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => setRetryKey(1), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if (retryKey === 1 && documentId) {
|
||||
getCachedFileBlob(documentId).then((blob) => {
|
||||
if (blob) {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
}
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
objectUrlRef.current = objUrl
|
||||
setActiveSrc(objUrl)
|
||||
setRetryKey(2)
|
||||
} else {
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setImageError(true)
|
||||
setIsLoading(false)
|
||||
}, [retryKey])
|
||||
}, [retryKey, documentId])
|
||||
|
||||
if (imageError || !url) {
|
||||
if (imageError || !activeSrc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-[#737373]">
|
||||
<p>Failed to load image</p>
|
||||
|
|
@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundImage: `url(${activeSrc})`,
|
||||
filter: "blur(100px)",
|
||||
transform: "scale(1.1)",
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
|
|
@ -52,7 +81,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
|
|||
<div className="absolute inset-0 bg-black/30" />
|
||||
<img
|
||||
key={retryKey}
|
||||
src={url}
|
||||
src={activeSrc}
|
||||
alt={title || "Image preview"}
|
||||
className={cn(
|
||||
"relative max-w-full max-h-full size-auto object-contain z-10",
|
||||
|
|
|
|||
|
|
@ -88,7 +88,13 @@ export function DocumentContent({
|
|||
|
||||
switch (contentType) {
|
||||
case "image":
|
||||
return <ImagePreview url={document.url ?? ""} title={document.title} />
|
||||
return (
|
||||
<ImagePreview
|
||||
url={document.url ?? ""}
|
||||
title={document.title}
|
||||
documentId={document.id}
|
||||
/>
|
||||
)
|
||||
|
||||
case "tweet":
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,23 +1,55 @@
|
|||
"use client"
|
||||
|
||||
import { Document, Page, pdfjs } from "react-pdf"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css"
|
||||
import "react-pdf/dist/Page/TextLayer.css"
|
||||
import { getCachedFileBlob } from "@/lib/file-cache"
|
||||
|
||||
// Configure PDF.js worker to use local package
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString()
|
||||
|
||||
type FileSource = string | { url: string; withCredentials: boolean } | null
|
||||
|
||||
interface PdfViewerProps {
|
||||
url: string | null | undefined
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
||||
const fileSource = useMemo(() => {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(null)
|
||||
const [cacheChecked, setCacheChecked] = useState(false)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let revoked = false
|
||||
if (!documentId) {
|
||||
setCacheChecked(true)
|
||||
return
|
||||
}
|
||||
|
||||
getCachedFileBlob(documentId).then((blob) => {
|
||||
if (revoked) return
|
||||
if (blob) {
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
objectUrlRef.current = objUrl
|
||||
setCachedUrl(objUrl)
|
||||
}
|
||||
setCacheChecked(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
revoked = true
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
}
|
||||
}
|
||||
}, [documentId])
|
||||
|
||||
const remoteFileSource: FileSource = useMemo(() => {
|
||||
if (!url) return null
|
||||
try {
|
||||
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
|
||||
|
|
@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
return url
|
||||
}, [url, documentId])
|
||||
|
||||
const backendProxySource: FileSource = useMemo(() => {
|
||||
if (!documentId) return null
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
|
||||
}, [documentId])
|
||||
|
||||
const [numPages, setNumPages] = useState<number | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [failedSources, setFailedSources] = useState(0)
|
||||
|
||||
if (!url) {
|
||||
const fileSource = useMemo((): FileSource => {
|
||||
if (cachedUrl) return cachedUrl
|
||||
if (failedSources === 0) return remoteFileSource
|
||||
if (failedSources === 1 && backendProxySource) return backendProxySource
|
||||
return null
|
||||
}, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
|
||||
|
||||
if (!cacheChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
Loading PDF…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!url && !cachedUrl) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
No PDF URL provided
|
||||
|
|
@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
setError(null)
|
||||
}
|
||||
|
||||
// On first failure, wait briefly then force a re-mount of the Document
|
||||
// component to retry (covers transient R2 timing issues).
|
||||
// On second failure, give up and show the error state.
|
||||
const onDocumentLoadError = useCallback(
|
||||
(err: Error) => {
|
||||
if (retryKey === 0) {
|
||||
setTimeout(() => {
|
||||
setRetryKey(1)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
function onDocumentLoadError(err: Error) {
|
||||
if (cachedUrl) {
|
||||
setError(err.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
},
|
||||
[retryKey],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const nextFailed = failedSources + 1
|
||||
const hasMoreSources =
|
||||
(nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
|
||||
|
||||
if (hasMoreSources) {
|
||||
setFailedSources(nextFailed)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(err.message || "Failed to load PDF")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
|
||||
|
|
@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
|
|||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
<Document
|
||||
key={retryKey}
|
||||
file={
|
||||
fileSource ||
|
||||
"https://corsproxy.io/?" +
|
||||
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
|
||||
}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
className="w-full"
|
||||
>
|
||||
{numPages && (
|
||||
<div className="flex flex-col items-center gap-4 py-4 w-full">
|
||||
{Array.from(new Array(numPages), (_, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
renderTextLayer
|
||||
renderAnnotationLayer
|
||||
className="shadow-lg"
|
||||
width={630}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
{fileSource && (
|
||||
<div className="flex-1 overflow-auto w-full">
|
||||
<Document
|
||||
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
|
||||
file={fileSource}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
className="w-full"
|
||||
>
|
||||
{numPages && (
|
||||
<div className="flex flex-col items-center gap-4 py-4 w-full">
|
||||
{Array.from(new Array(numPages), (_, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
renderTextLayer
|
||||
renderAnnotationLayer
|
||||
className="shadow-lg"
|
||||
width={630}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Document>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,24 +47,6 @@ function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
|
|||
>
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
|
||||
)}
|
||||
>
|
||||
{parsed.formatLabel}
|
||||
</span>
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
|
||||
)}
|
||||
>
|
||||
{parsed.identifierLabel}: {parsed.identifierValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import type { UseMutationResult } from "@tanstack/react-query"
|
|||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { parsePluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginDetails } from "./plugin-details"
|
||||
import { useFullDocumentContent } from "@/hooks/use-full-document"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -199,16 +199,25 @@ export function DocumentModal({
|
|||
const isMobile = useIsMobile()
|
||||
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
|
||||
|
||||
const fullContent = useFullDocumentContent(_document?.id, isOpen)
|
||||
const effectiveDocument = useMemo(
|
||||
() =>
|
||||
_document && fullContent !== null
|
||||
? { ..._document, content: fullContent }
|
||||
: _document,
|
||||
[_document, fullContent],
|
||||
)
|
||||
|
||||
const { initialEditorContent, initialEditorString } = useMemo(() => {
|
||||
const content = _document?.content as string | null | undefined
|
||||
const content = effectiveDocument?.content as string | null | undefined
|
||||
return {
|
||||
initialEditorContent: content ?? undefined,
|
||||
initialEditorString: content ?? "",
|
||||
}
|
||||
}, [_document?.content])
|
||||
}, [effectiveDocument?.content])
|
||||
const pluginDocument = useMemo(
|
||||
() => parsePluginDocument(_document),
|
||||
[_document],
|
||||
() => parsePluginDocument(effectiveDocument),
|
||||
[effectiveDocument],
|
||||
)
|
||||
|
||||
const [draftContentString, setDraftContentString] =
|
||||
|
|
@ -270,13 +279,8 @@ export function DocumentModal({
|
|||
],
|
||||
)
|
||||
|
||||
const hasPluginInsights =
|
||||
pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session"
|
||||
const hasDocumentInsights = Boolean(
|
||||
hasPluginInsights ||
|
||||
_document?.summary ||
|
||||
_document?.summary ||
|
||||
pluginDocument?.summary ||
|
||||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
|
||||
)
|
||||
|
|
@ -287,7 +291,7 @@ export function DocumentModal({
|
|||
className="bg-[#14161A] rounded-[14px] overflow-hidden flex min-h-0 flex-1 flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative"
|
||||
>
|
||||
<DocumentContent
|
||||
document={_document}
|
||||
document={effectiveDocument}
|
||||
textEditorProps={textEditorProps}
|
||||
pluginDocument={pluginDocument}
|
||||
/>
|
||||
|
|
@ -303,7 +307,6 @@ export function DocumentModal({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import Image from "next/image"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
|
||||
function DetailPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
|
||||
Details
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginIconSrc && (
|
||||
<Image
|
||||
src={parsed.pluginIconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<DetailPill label="Format" value={parsed.formatLabel} />
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<DetailPill
|
||||
label={parsed.identifierLabel}
|
||||
value={parsed.identifierValue}
|
||||
/>
|
||||
)}
|
||||
{parsed.clientLabel && parsed.clientValue && (
|
||||
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
|
||||
)}
|
||||
</div>
|
||||
{parsed.artifacts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Outputs
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{parsed.artifacts.map((artifact, index) => (
|
||||
<DetailPill
|
||||
key={`${artifact.label}-${artifact.value}-${index}`}
|
||||
label={artifact.label}
|
||||
value={artifact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
|
|
@ -52,6 +53,7 @@ export function DocumentsCommandPalette({
|
|||
}: DocumentsCommandPaletteProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const router = useRouter()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
|
@ -86,7 +88,7 @@ export function DocumentsCommandPalette({
|
|||
id: "settings",
|
||||
label: "Go to Settings",
|
||||
icon: <Settings className="size-4 text-[#737373]" />,
|
||||
action: () => close(() => router.push("/settings")),
|
||||
action: () => close(() => openSettings()),
|
||||
},
|
||||
...(onAddMemory
|
||||
? [
|
||||
|
|
|
|||
206
apps/web/components/edit-space-modal.tsx
Normal file
206
apps/web/components/edit-space-modal.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"use client"
|
||||
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Loader2, XIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useSpaceContext, useUpdateSpace } from "@/hooks/use-space-context"
|
||||
import { CONTEXT_PRESETS } from "@/components/add-space-modal"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
|
||||
const INPUT_SHADOW =
|
||||
"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 EditSpaceModalProps = {
|
||||
containerTag: string
|
||||
currentName: string
|
||||
currentEmoji?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditSpaceModal({
|
||||
containerTag,
|
||||
currentName,
|
||||
currentEmoji = "📁",
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditSpaceModalProps) {
|
||||
const isDefault = !containerTag || containerTag === DEFAULT_PROJECT_ID
|
||||
const { data, isLoading } = useSpaceContext(containerTag, open)
|
||||
const update = useUpdateSpace()
|
||||
const [name, setName] = useState(currentName)
|
||||
const [context, setContext] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(data?.name ?? currentName ?? "")
|
||||
setContext(data?.entityContext ?? "")
|
||||
}, [open, data?.name, data?.entityContext, currentName])
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate(
|
||||
{
|
||||
containerTag,
|
||||
name: isDefault ? undefined : name.trim() || undefined,
|
||||
entityContext: context.trim() ? context.trim() : null,
|
||||
},
|
||||
{ onSuccess: () => onOpenChange(false) },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Edit space
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[#737373] text-[13px] leading-snug",
|
||||
)}
|
||||
>
|
||||
Rename this space and tell Nova what to remember in it.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
data-slot="dialog-close"
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-[6px] items-center">
|
||||
<div
|
||||
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex size-[45px] shrink-0 items-center justify-center rounded-[12px] p-3"
|
||||
style={{ boxShadow: INPUT_SHADOW }}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="text-xl">{currentEmoji}</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isDefault || update.isPending}
|
||||
placeholder="Space name"
|
||||
maxLength={120}
|
||||
className={cn(
|
||||
"flex-1 bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{ boxShadow: INPUT_SHADOW }}
|
||||
/>
|
||||
</div>
|
||||
{isDefault && (
|
||||
<span className="pl-1 text-[11px] text-[#525966]">
|
||||
The default space can't be renamed.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 pl-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] font-medium text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
What to remember
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-[#525966]">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
disabled={isLoading || update.isPending}
|
||||
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
|
||||
maxLength={750}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
|
||||
)}
|
||||
style={{ boxShadow: INPUT_SHADOW }}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pl-1">
|
||||
<span className="text-[11px] text-[#737373]">Try a preset</span>
|
||||
{CONTEXT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setContext(preset.text)}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={update.isPending}
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleSave}
|
||||
disabled={update.isPending}
|
||||
className="px-4 py-[10px] rounded-full"
|
||||
>
|
||||
{update.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,11 +22,14 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
const searchParams = useSearchParams()
|
||||
const { session, organizations, isRestoring } = useAuth()
|
||||
|
||||
const isMcpPublicPage = searchParams.get("view") === "mcp"
|
||||
const isPublicAppPage =
|
||||
pathname === "/" &&
|
||||
["integrations", "mcp"].includes(searchParams.get("view") ?? "")
|
||||
const isGuestPublicAppPage = isPublicAppPage && !session
|
||||
const isOnboarding = pathname.startsWith("/onboarding")
|
||||
|
||||
useEffect(() => {
|
||||
if (isMcpPublicPage) return
|
||||
if (isGuestPublicAppPage) return
|
||||
if (isRestoring) return
|
||||
if (!session) {
|
||||
router.replace(
|
||||
|
|
@ -44,11 +47,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
isRestoring,
|
||||
isOnboarding,
|
||||
router,
|
||||
isMcpPublicPage,
|
||||
isGuestPublicAppPage,
|
||||
])
|
||||
|
||||
const showLoading =
|
||||
!isMcpPublicPage &&
|
||||
!isGuestPublicAppPage &&
|
||||
(isRestoring ||
|
||||
(!session && !isRestoring) ||
|
||||
(session && organizations === null) ||
|
||||
|
|
|
|||
|
|
@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({
|
|||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col w-full">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible
|
||||
maxNodes={undefined}
|
||||
canvasRef={canvasRef}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { useProject } from "@/stores"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { SpaceSelector } from "./space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
|
@ -44,6 +43,7 @@ import { useCustomer } from "autumn-js/react"
|
|||
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"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
|
|
@ -74,7 +74,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
|
||||
)
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const router = useRouter()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const isMobile = useIsMobile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useQueryState(
|
||||
"feedback",
|
||||
|
|
@ -207,6 +207,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
enableDelete
|
||||
enableEdit
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -421,7 +422,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/settings")}
|
||||
onClick={() => openSettings()}
|
||||
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"
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
|
|
@ -504,7 +505,41 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function PublicHeader() {
|
||||
export function PublicHeader({
|
||||
variant = "default",
|
||||
}: {
|
||||
variant?: "default" | "integrations"
|
||||
}) {
|
||||
if (variant === "integrations") {
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
|
||||
<Link
|
||||
href="/?view=integrations"
|
||||
className="flex items-center gap-2 transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
<p className="text-base leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link href="/login?redirect=%2F%3Fview%3Dintegrations">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-10 cursor-pointer items-center gap-2 rounded-full px-4 text-[14px] font-medium text-white transition-opacity hover:opacity-95 sm:px-5 sm:text-[15px]",
|
||||
"bg-[linear-gradient(100deg,#426BFF_0%,#2D1CFF_100%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.16)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Logo className="h-4 w-5 shrink-0" />
|
||||
Log in with Supermemory
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
|
||||
<Link
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,15 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Loader,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
|||
import {
|
||||
PLUGIN_CATALOG,
|
||||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
|
|
@ -27,9 +36,20 @@ interface ConnectedPlugin {
|
|||
keyId: string
|
||||
pluginId: string
|
||||
createdAt: string
|
||||
lastRequest?: string | null
|
||||
keyStart?: string | null
|
||||
}
|
||||
|
||||
type ListedApiKey = {
|
||||
id: string
|
||||
name?: string | null
|
||||
createdAt: string
|
||||
enabled?: boolean
|
||||
lastRequest: string | null
|
||||
metadata: string | Record<string, unknown> | null
|
||||
start?: string | null
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
|
|
@ -121,13 +141,77 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ConnectedPill({
|
||||
function toDate(value: string | Date | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Never"
|
||||
const absMs = Math.abs(Date.now() - date.getTime())
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (absMs < minute) return "Just now"
|
||||
if (absMs < hour) return `${Math.round(absMs / minute)}m ago`
|
||||
if (absMs < day) return `${Math.round(absMs / hour)}h ago`
|
||||
return `${Math.round(absMs / day)}d ago`
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date | null | undefined): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return "Unknown"
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function maskKey(start: string | null | undefined): string {
|
||||
if (!start) return "sm_********"
|
||||
return `${start}********`
|
||||
}
|
||||
|
||||
function keyPrefix(key: ListedApiKey): string | null {
|
||||
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
|
||||
}
|
||||
|
||||
function DetailStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivePill({
|
||||
plugin,
|
||||
connectedKeys,
|
||||
onRevoke,
|
||||
}: {
|
||||
plugin: PluginInfo
|
||||
connectedKeys: ConnectedPlugin[]
|
||||
onRevoke: (keyId: string) => void
|
||||
}) {
|
||||
const primaryKey = connectedKeys[0]
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -140,7 +224,7 @@ function ConnectedPill({
|
|||
)}
|
||||
>
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
ACTIVE
|
||||
<ChevronDown className="size-3 text-[#737373]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -148,13 +232,67 @@ function ConnectedPill({
|
|||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
|
||||
"w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<PluginIconBox src={plugin.icon} alt={plugin.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[15px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Active</p>
|
||||
</div>
|
||||
<span className="size-3 rounded-full bg-[#37D67A]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 border-y border-white/[0.08] py-4 sm:grid-cols-3">
|
||||
<DetailStat label="API key" value={maskKey(primaryKey?.keyStart)} />
|
||||
<DetailStat
|
||||
label="Last active"
|
||||
value={formatRelativeTime(primaryKey?.lastRequest)}
|
||||
/>
|
||||
<DetailStat
|
||||
label="Connected"
|
||||
value={formatRelativeTime(primaryKey?.createdAt)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{plugin.docsUrl && (
|
||||
<a
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
Docs <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
{plugin.githubUrl && (
|
||||
<a
|
||||
href={plugin.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
|
||||
>
|
||||
GitHub <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] text-[#737373]">
|
||||
Connected {formatDate(primaryKey?.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
"px-2 pb-1.5 pt-4 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connectedKeys.length > 1
|
||||
|
|
@ -188,22 +326,26 @@ function PluginRow({
|
|||
plugin,
|
||||
pluginId,
|
||||
connectedKeys,
|
||||
needsSetup,
|
||||
needsProUpgrade,
|
||||
isConnecting,
|
||||
actionsDisabled,
|
||||
onConnect,
|
||||
onUpgrade,
|
||||
onRevoke,
|
||||
onFinishSetup,
|
||||
}: {
|
||||
plugin: PluginInfo
|
||||
pluginId: string
|
||||
connectedKeys: ConnectedPlugin[]
|
||||
needsSetup: boolean
|
||||
needsProUpgrade: boolean
|
||||
isConnecting: boolean
|
||||
actionsDisabled: boolean
|
||||
onConnect: (id: string) => void
|
||||
onUpgrade: () => void
|
||||
onRevoke: (keyId: string) => void
|
||||
onFinishSetup: (id: string) => void
|
||||
}) {
|
||||
const isConnected = connectedKeys.length > 0
|
||||
return (
|
||||
|
|
@ -240,7 +382,16 @@ function PluginRow({
|
|||
<div className="col-start-2 flex min-w-0 shrink-0 items-center gap-2 sm:col-start-auto sm:gap-4">
|
||||
{plugin.docsUrl && <DocsLink href={plugin.docsUrl} />}
|
||||
{isConnected ? (
|
||||
<ConnectedPill connectedKeys={connectedKeys} onRevoke={onRevoke} />
|
||||
<ActivePill
|
||||
plugin={plugin}
|
||||
connectedKeys={connectedKeys}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
) : needsSetup ? (
|
||||
<PillButton onClick={() => onFinishSetup(pluginId)}>
|
||||
<span className="size-[7px] rounded-full bg-[#EAB308]" />
|
||||
Finish setup
|
||||
</PillButton>
|
||||
) : needsProUpgrade ? (
|
||||
<PillButton onClick={onUpgrade}>
|
||||
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
|
||||
|
|
@ -307,6 +458,9 @@ export function PluginsDetail() {
|
|||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
|
|
@ -332,21 +486,51 @@ export function PluginsDetail() {
|
|||
queryKey: ["plugins"],
|
||||
})
|
||||
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
|
||||
{
|
||||
enabled: !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/keys`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { keys?: ListedApiKey[] }
|
||||
return data.keys ?? []
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
},
|
||||
queryKey: ["api-keys", org?.id],
|
||||
})
|
||||
)
|
||||
|
||||
const setupPluginIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
typeof key.metadata === "string"
|
||||
? (JSON.parse(key.metadata) as {
|
||||
sm_type?: string
|
||||
sm_client?: string
|
||||
})
|
||||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
ids.add(normalizePluginClientId(metadata.sm_client))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ids
|
||||
}, [apiKeys])
|
||||
|
||||
const connectedPlugins = useMemo<ConnectedPlugin[]>(() => {
|
||||
const plugins: ConnectedPlugin[] = []
|
||||
for (const key of apiKeys) {
|
||||
if (key.enabled === false) continue
|
||||
if (!key.lastRequest) continue
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
|
|
@ -358,12 +542,15 @@ export function PluginsDetail() {
|
|||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
const createdAt = toDate(key.createdAt)?.toISOString()
|
||||
const lastRequest = toDate(key.lastRequest)?.toISOString()
|
||||
plugins.push({
|
||||
id: key.id,
|
||||
keyId: key.id,
|
||||
pluginId: metadata.sm_client,
|
||||
createdAt: key.createdAt.toISOString(),
|
||||
keyStart: key.start ?? null,
|
||||
pluginId: normalizePluginClientId(metadata.sm_client),
|
||||
createdAt: createdAt ?? new Date().toISOString(),
|
||||
lastRequest: lastRequest ?? null,
|
||||
keyStart: keyPrefix(key),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -463,13 +650,18 @@ export function PluginsDetail() {
|
|||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
: undefined
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
// If a step already embeds the key (an `export …="sm_…"` line), don't also
|
||||
// show the bare key in its own step — that's the repetition to avoid.
|
||||
// Otherwise (wizard-style installs) lead with a copy-the-key step.
|
||||
// Otherwise (wizard-style installs) lead with a copy-the-key step, unless
|
||||
// the plugin performs browser OAuth itself.
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const setupSteps: InstallStep[] = stepsEmbedKey
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
const setupSteps: InstallStep[] = skipGeneratedKeyStep
|
||||
? pluginSteps
|
||||
: [
|
||||
{
|
||||
|
|
@ -512,10 +704,15 @@ export function PluginsDetail() {
|
|||
connectedKeys={connectedPlugins.filter(
|
||||
(p) => p.pluginId === pluginId,
|
||||
)}
|
||||
needsSetup={
|
||||
!connectedPluginIds.has(pluginId) &&
|
||||
setupPluginIds.has(pluginId)
|
||||
}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
isConnecting={connectingPlugin === pluginId}
|
||||
actionsDisabled={!!connectingPlugin}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onFinishSetup={(id) => setFinishSetupPluginId(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
|
|
@ -638,6 +835,97 @@ export function PluginsDetail() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<PluginIconBox
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
Complete install in the tool — status becomes active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupPlugin?.installSteps?.length ? (
|
||||
<InstallSteps steps={finishSetupPlugin.installSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,18 @@ import Image from "next/image"
|
|||
import { useId, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ShortcutType = "add" | "search"
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -45,6 +49,7 @@ function PillButton({
|
|||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -52,17 +57,14 @@ function PillButton({
|
|||
)
|
||||
}
|
||||
|
||||
export function ShortcutsDetail() {
|
||||
export function useShortcutsConnect() {
|
||||
const { org } = useAuth()
|
||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
||||
const [showSetupDialog, setShowSetupDialog] = useState(false)
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [selectedShortcutType, setSelectedShortcutType] = useState<
|
||||
"add" | "search" | null
|
||||
>(null)
|
||||
const apiKeyId = useId()
|
||||
const [shortcutType, setShortcutType] = useState<ShortcutType | null>(null)
|
||||
|
||||
const handleCopyApiKey = async (key: string) => {
|
||||
const copyApiKey = async (key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
setCopied(true)
|
||||
|
|
@ -73,20 +75,24 @@ export function ShortcutsDetail() {
|
|||
}
|
||||
}
|
||||
|
||||
const createApiKeyMutation = useMutation({
|
||||
const createKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!org?.id) throw new Error("Organization ID is required")
|
||||
const res = await authClient.apiKey.create({
|
||||
metadata: { organizationId: org?.id, type: "ios-shortcut" },
|
||||
metadata: { organizationId: org.id, type: "ios-shortcut" },
|
||||
name: `ios-${generateId().slice(0, 8)}`,
|
||||
prefix: `sm_${org?.id}_`,
|
||||
prefix: `sm_${org.id}_`,
|
||||
})
|
||||
return res.key
|
||||
if (res.error)
|
||||
throw new Error(res.error.message ?? "Failed to create API key")
|
||||
if (!res.data?.key) throw new Error("API key missing from response")
|
||||
return res.data.key
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
setApiKey(key)
|
||||
setShowApiKeyModal(true)
|
||||
setShowSetupDialog(true)
|
||||
setCopied(false)
|
||||
handleCopyApiKey(key)
|
||||
copyApiKey(key)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create API key", {
|
||||
|
|
@ -95,19 +101,224 @@ export function ShortcutsDetail() {
|
|||
},
|
||||
})
|
||||
|
||||
const handleShortcutClick = (type: "add" | "search") => {
|
||||
setSelectedShortcutType(type)
|
||||
createApiKeyMutation.mutate()
|
||||
const connect = (type: ShortcutType) => {
|
||||
if (createKeyMutation.isPending) return
|
||||
setShortcutType(type)
|
||||
createKeyMutation.mutate()
|
||||
}
|
||||
|
||||
const handleOpenShortcut = () => {
|
||||
if (selectedShortcutType === "add") {
|
||||
const openShortcut = () => {
|
||||
if (shortcutType === "add") {
|
||||
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
|
||||
} else if (selectedShortcutType === "search") {
|
||||
} else if (shortcutType === "search") {
|
||||
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = (
|
||||
<ShortcutsSetupDialog
|
||||
apiKey={apiKey}
|
||||
copied={copied}
|
||||
onCopy={() => copyApiKey(apiKey)}
|
||||
onOpenChange={(open) => {
|
||||
setShowSetupDialog(open)
|
||||
if (!open) {
|
||||
setShortcutType(null)
|
||||
setApiKey("")
|
||||
setCopied(false)
|
||||
}
|
||||
}}
|
||||
onOpenShortcut={openShortcut}
|
||||
open={showSetupDialog}
|
||||
shortcutType={shortcutType}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
connect,
|
||||
dialog,
|
||||
isPending: createKeyMutation.isPending,
|
||||
pendingType: createKeyMutation.isPending ? shortcutType : null,
|
||||
}
|
||||
}
|
||||
|
||||
export type ShortcutsConnectController = ReturnType<typeof useShortcutsConnect>
|
||||
|
||||
export function ShortcutsConnectButtons({
|
||||
controller,
|
||||
}: {
|
||||
controller: ShortcutsConnectController
|
||||
}) {
|
||||
const { connect, isPending, pendingType } = controller
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
connect("add")
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingType === "add" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
|
||||
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
connect("search")
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{pendingType === "search" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
|
||||
{pendingType === "search" ? "Creating..." : "Search memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutsSetupDialog({
|
||||
apiKey,
|
||||
copied,
|
||||
onCopy,
|
||||
onOpenChange,
|
||||
onOpenShortcut,
|
||||
open,
|
||||
shortcutType,
|
||||
}: {
|
||||
apiKey: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenShortcut: () => void
|
||||
open: boolean
|
||||
shortcutType: ShortcutType | null
|
||||
}) {
|
||||
const apiKeyId = useId()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[#FAFAFA] text-lg font-semibold",
|
||||
)}
|
||||
>
|
||||
Setup Apple Shortcut
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={apiKeyId}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type="text"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className={cn(
|
||||
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
'Click "Add to Shortcuts" below to open the shortcut',
|
||||
"Paste your API key when prompted",
|
||||
"Start using your shortcut!",
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenShortcut}
|
||||
disabled={!shortcutType}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-lg h-11 px-4 font-medium text-sm",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/images/ios-shortcuts.png"
|
||||
alt="iOS Shortcuts"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Add to Shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutsDetail() {
|
||||
const controller = useShortcutsConnect()
|
||||
const { connect, dialog, isPending, pendingType } = controller
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -140,36 +351,24 @@ export function ShortcutsDetail() {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<PillButton
|
||||
onClick={() => handleShortcutClick("add")}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add" ? (
|
||||
<PillButton onClick={() => connect("add")} disabled={isPending}>
|
||||
{pendingType === "add" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "add"
|
||||
? "Creating..."
|
||||
: "Add memory shortcut"}
|
||||
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
|
||||
</span>
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={() => handleShortcutClick("search")}
|
||||
disabled={createApiKeyMutation.isPending}
|
||||
>
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search" ? (
|
||||
<PillButton onClick={() => connect("search")} disabled={isPending}>
|
||||
{pendingType === "search" ? (
|
||||
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
|
||||
) : (
|
||||
<Search className="size-4 text-[#FAFAFA]" />
|
||||
)}
|
||||
<span className="text-[14px] text-[#FAFAFA] font-medium">
|
||||
{createApiKeyMutation.isPending &&
|
||||
selectedShortcutType === "search"
|
||||
{pendingType === "search"
|
||||
? "Creating..."
|
||||
: "Search memory shortcut"}
|
||||
</span>
|
||||
|
|
@ -177,120 +376,7 @@ export function ShortcutsDetail() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showApiKeyModal}
|
||||
onOpenChange={(open: boolean) => {
|
||||
setShowApiKeyModal(open)
|
||||
if (!open) {
|
||||
setSelectedShortcutType(null)
|
||||
setApiKey("")
|
||||
setCopied(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[#FAFAFA] text-lg font-semibold",
|
||||
)}
|
||||
>
|
||||
Setup Apple Shortcut
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={apiKeyId}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Your API Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={apiKeyId}
|
||||
type="text"
|
||||
value={apiKey}
|
||||
readOnly
|
||||
className={cn(
|
||||
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyApiKey(apiKey)}
|
||||
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Follow these steps:
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
'Click "Add to Shortcuts" below to open the shortcut',
|
||||
"Paste your API key when prompted",
|
||||
"Start using your shortcut!",
|
||||
].map((text, i) => (
|
||||
<div key={text} className="flex items-start gap-3">
|
||||
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-sm text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenShortcut}
|
||||
disabled={!selectedShortcutType}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-lg h-11 px-4 font-medium text-sm",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/images/ios-shortcuts.png"
|
||||
alt="iOS Shortcuts"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Add to Shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
{dialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { memo, useMemo } from "react"
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Expand } from "lucide-react"
|
||||
import { SuperLoader } from "@/components/superloader"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
|
|
@ -157,7 +158,12 @@ export const GraphCard = memo<GraphCardProps>(
|
|||
<div className="flex-1 w-full relative overflow-hidden rounded-lg">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
<SuperLoader
|
||||
label="Loading graph..."
|
||||
size={40}
|
||||
colorClassName="text-[#4BA0FA]"
|
||||
className="[&>span]:text-[#A8B0BD]"
|
||||
/>
|
||||
</div>
|
||||
) : documentCount > 0 || memoryCount > 0 ? (
|
||||
<StaticGraphPreview
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type {
|
||||
GraphApiDocument,
|
||||
|
|
@ -9,12 +9,13 @@ import type {
|
|||
MemoryRelation,
|
||||
} from "@supermemory/memory-graph"
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const PAGE_SIZE = 500
|
||||
|
||||
interface UseGraphApiOptions {
|
||||
containerTags?: string[]
|
||||
documentIds?: string[]
|
||||
enabled?: boolean
|
||||
maxNodes?: number
|
||||
}
|
||||
|
||||
interface ApiMemoryEntry {
|
||||
|
|
@ -59,6 +60,13 @@ interface ApiDocumentsResponse {
|
|||
}
|
||||
}
|
||||
|
||||
function getGraphNodeCount(documents: ApiDocument[]): number {
|
||||
return documents.reduce(
|
||||
(total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
|
||||
return {
|
||||
id: mem.id,
|
||||
|
|
@ -108,7 +116,7 @@ function toGraphDocument(
|
|||
}
|
||||
|
||||
export function useGraphApi(options: UseGraphApiOptions = {}) {
|
||||
const { containerTags, documentIds, enabled = true } = options
|
||||
const { containerTags, documentIds, enabled = true, maxNodes } = options
|
||||
const filteredDocumentIds = documentIds?.filter(Boolean)
|
||||
const hasDocumentIds =
|
||||
filteredDocumentIds != null && filteredDocumentIds.length > 0
|
||||
|
|
@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
containerTags,
|
||||
[],
|
||||
filteredDocumentIds,
|
||||
maxNodes,
|
||||
],
|
||||
initialPageParam: 1,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
|
|
@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
|
||||
return response.data as unknown as ApiDocumentsResponse
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (hasDocumentIds) return undefined
|
||||
if (maxNodes != null) {
|
||||
const loadedNodes = allPages.reduce(
|
||||
(total, page) => total + getGraphNodeCount(page.documents ?? []),
|
||||
0,
|
||||
)
|
||||
if (loadedNodes >= maxNodes) return undefined
|
||||
}
|
||||
|
||||
const { currentPage, totalPages } = lastPage.pagination
|
||||
return currentPage < totalPages ? currentPage + 1 : undefined
|
||||
},
|
||||
|
|
@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
|
|||
enabled,
|
||||
})
|
||||
|
||||
const loadedNodeCount = useMemo(() => {
|
||||
if (!data?.pages) return 0
|
||||
return data.pages.reduce(
|
||||
(total, page) => total + getGraphNodeCount(page.documents ?? []),
|
||||
0,
|
||||
)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || hasDocumentIds) return
|
||||
if (!hasNextPage || isFetchingNextPage) return
|
||||
if (maxNodes != null && loadedNodeCount >= maxNodes) return
|
||||
fetchNextPage()
|
||||
}, [
|
||||
enabled,
|
||||
hasDocumentIds,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
loadedNodeCount,
|
||||
maxNodes,
|
||||
fetchNextPage,
|
||||
])
|
||||
|
||||
const documents = useMemo(() => {
|
||||
if (!data?.pages) return []
|
||||
return data.pages.flatMap((page) =>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
|
||||
import type { GraphThemeColors } from "@supermemory/memory-graph"
|
||||
import { SuperLoader } from "@/components/superloader"
|
||||
import { useGraphApi } from "./hooks/use-graph-api"
|
||||
|
||||
export interface MemoryGraphWrapperProps {
|
||||
|
|
@ -34,20 +34,6 @@ export function MemoryGraph({
|
|||
canvasRef,
|
||||
...rest
|
||||
}: MemoryGraphWrapperProps) {
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
documents,
|
||||
isLoading: apiIsLoading,
|
||||
|
|
@ -59,16 +45,17 @@ export function MemoryGraph({
|
|||
} = useGraphApi({
|
||||
containerTags,
|
||||
documentIds,
|
||||
enabled: containerSize.width > 0 && containerSize.height > 0,
|
||||
maxNodes,
|
||||
})
|
||||
const isInitialLoading = externalIsLoading || apiIsLoading
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="size-full [&>div]:!bg-none">
|
||||
<div className="absolute inset-0 [&>div]:!h-full [&>div]:!bg-none">
|
||||
<MemoryGraphBase
|
||||
documents={documents}
|
||||
isLoading={externalIsLoading || apiIsLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={hasMore ? () => loadMore() : undefined}
|
||||
isLoading={false}
|
||||
isLoadingMore={false}
|
||||
onLoadMore={hasMore && !isLoadingMore ? () => loadMore() : undefined}
|
||||
hasMore={hasMore}
|
||||
error={externalError || apiError}
|
||||
variant={variant}
|
||||
|
|
@ -85,6 +72,16 @@ export function MemoryGraph({
|
|||
>
|
||||
{children}
|
||||
</MemoryGraphBase>
|
||||
{isInitialLoading && (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
|
||||
<SuperLoader
|
||||
label="Loading memory graph..."
|
||||
size={72}
|
||||
colorClassName="text-[#4BA0FA]"
|
||||
className="[&>span]:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
53
apps/web/components/onboarding-brain/onboarding-confetti.tsx
Normal file
53
apps/web/components/onboarding-brain/onboarding-confetti.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
|
||||
const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"]
|
||||
|
||||
export function OnboardingConfetti() {
|
||||
const [onboarded, setOnboarded] = useQueryState("onboarded")
|
||||
const fired = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (onboarded !== "1" || fired.current) return
|
||||
fired.current = true
|
||||
setOnboarded(null)
|
||||
|
||||
const reduceMotion = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches
|
||||
if (reduceMotion) return
|
||||
|
||||
let raf = 0
|
||||
const run = async () => {
|
||||
const confetti = (await import("canvas-confetti")).default
|
||||
const end = Date.now() + 1400
|
||||
const frame = () => {
|
||||
confetti({
|
||||
particleCount: 4,
|
||||
angle: 60,
|
||||
spread: 70,
|
||||
startVelocity: 55,
|
||||
origin: { x: 0, y: 0.7 },
|
||||
colors: COLORS,
|
||||
})
|
||||
confetti({
|
||||
particleCount: 4,
|
||||
angle: 120,
|
||||
spread: 70,
|
||||
startVelocity: 55,
|
||||
origin: { x: 1, y: 0.7 },
|
||||
colors: COLORS,
|
||||
})
|
||||
if (Date.now() < end) raf = requestAnimationFrame(frame)
|
||||
}
|
||||
frame()
|
||||
}
|
||||
run()
|
||||
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [onboarded, setOnboarded])
|
||||
|
||||
return null
|
||||
}
|
||||
151
apps/web/components/onboarding-brain/shell.tsx
Normal file
151
apps/web/components/onboarding-brain/shell.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"use client"
|
||||
|
||||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { motion } from "motion/react"
|
||||
import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
|
||||
|
||||
interface ShellProps {
|
||||
step: BrainStep
|
||||
domain?: string | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BrainShell({ step, children }: ShellProps) {
|
||||
const visibleSteps: BrainStep[] = BRAIN_STEPS
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<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%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center px-6 md:px-10 py-4 gap-4">
|
||||
<LogoFull className="h-5 md:h-6 text-[#fafafa] justify-self-start" />
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
<span aria-hidden />
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"relative z-10 flex-1 flex justify-center px-4 md:px-10 py-6 md:py-10",
|
||||
step === "sources"
|
||||
? "items-start overflow-y-auto"
|
||||
: "items-center overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
step === "sources" ? "max-w-none" : "max-w-5xl",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepIndicator({
|
||||
step,
|
||||
visibleSteps,
|
||||
}: {
|
||||
step: BrainStep
|
||||
visibleSteps: BrainStep[]
|
||||
}) {
|
||||
const currentIdx = visibleSteps.indexOf(step)
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
{visibleSteps.map((s, i) => {
|
||||
const isDone = i < currentIdx
|
||||
const isCurrent = i === currentIdx
|
||||
const isLast = i === visibleSteps.length - 1
|
||||
return (
|
||||
<div key={s} className="flex items-start">
|
||||
<div className="flex flex-col items-center gap-1.5 min-w-[58px]">
|
||||
<StepDot done={isDone} current={isCurrent} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium transition-colors leading-none",
|
||||
isCurrent
|
||||
? "text-[#fafafa]"
|
||||
: isDone
|
||||
? "text-[#737373]"
|
||||
: "text-[#525D6E]",
|
||||
)}
|
||||
>
|
||||
{BRAIN_STEP_LABELS[s]}
|
||||
</span>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
background: isDone
|
||||
? "linear-gradient(to right, #4BA0FA, rgba(75,160,250,0.4))"
|
||||
: "#2E353D",
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepDot({ done, current }: { done: boolean; current: boolean }) {
|
||||
if (current) {
|
||||
return (
|
||||
<div className="relative size-3 flex items-center justify-center">
|
||||
<motion.span
|
||||
layoutId="brain-step-ring"
|
||||
className="absolute inset-0 rounded-full border-2 border-[#4BA0FA]"
|
||||
/>
|
||||
<span className="size-1.5 rounded-full bg-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (done) {
|
||||
return (
|
||||
<div className="size-3 flex items-center justify-center">
|
||||
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="size-3 flex items-center justify-center">
|
||||
<span className="size-2.5 rounded-full border border-[#2E353D]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
473
apps/web/components/onboarding-brain/step-about.tsx
Normal file
473
apps/web/components/onboarding-brain/step-about.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { Textarea } from "@ui/components/textarea"
|
||||
import {
|
||||
ArrowRight,
|
||||
Brain,
|
||||
Building2,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Plug,
|
||||
Terminal,
|
||||
User2,
|
||||
UserPlus,
|
||||
Users2,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import type { BrainMode } from "./types"
|
||||
|
||||
export interface AboutValues {
|
||||
name: string
|
||||
about: string
|
||||
workspaceName: string
|
||||
workspaceDomain: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: BrainMode
|
||||
onModeChange: (m: BrainMode) => void
|
||||
domain: string | null
|
||||
suggestedWorkspaceName: string
|
||||
defaultName: string
|
||||
avatarUrl: string | null
|
||||
values: AboutValues
|
||||
onChange: (next: AboutValues) => void
|
||||
onContinue: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
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 = {
|
||||
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 =
|
||||
"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({
|
||||
mode,
|
||||
onModeChange,
|
||||
domain,
|
||||
suggestedWorkspaceName,
|
||||
defaultName,
|
||||
avatarUrl,
|
||||
values,
|
||||
onChange,
|
||||
onContinue,
|
||||
submitting,
|
||||
}: Props) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
|
||||
useEffect(() => {
|
||||
const patch: Partial<AboutValues> = {}
|
||||
if (!values.name && defaultName) patch.name = defaultName
|
||||
if (!values.workspaceName && suggestedWorkspaceName) {
|
||||
patch.workspaceName = suggestedWorkspaceName
|
||||
}
|
||||
if (!values.workspaceDomain && domain) {
|
||||
patch.workspaceDomain = domain
|
||||
}
|
||||
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
|
||||
}, [defaultName, suggestedWorkspaceName, domain])
|
||||
|
||||
const canContinue =
|
||||
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}
|
||||
>
|
||||
<UserAvatar
|
||||
url={avatarUrl}
|
||||
name={values.name || defaultName}
|
||||
className="size-14 mb-5"
|
||||
/>
|
||||
<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="mt-6 space-y-5">
|
||||
<div>
|
||||
<p className={fieldLabel}>Your name</p>
|
||||
<Input
|
||||
value={values.name}
|
||||
onChange={(e) => onChange({ ...values, name: e.target.value })}
|
||||
placeholder="e.g. Mahesh"
|
||||
className={inputClass}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={fieldLabel}>
|
||||
What are you here for?{" "}
|
||||
<span className="text-[#525D6E] font-medium">(optional)</span>
|
||||
</p>
|
||||
<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}
|
||||
className={cn(
|
||||
inputClass,
|
||||
"h-auto resize-none py-3 leading-[1.5]",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
|
||||
<div className="flex items-center justify-end gap-[22px] px-1">
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
Creating…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 }) {
|
||||
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`,
|
||||
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
|
||||
]
|
||||
const [idx, setIdx] = useState(0)
|
||||
if (idx >= sources.length) {
|
||||
return <Building2 className="size-5 text-[#737373]" />
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={sources[idx]}
|
||||
alt={domain}
|
||||
className="size-6 object-contain"
|
||||
onError={() => setIdx((i) => i + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 ModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
onChange: (m: BrainMode) => void
|
||||
}) {
|
||||
const items: { id: BrainMode; label: string }[] = [
|
||||
{ id: "personal", label: "Personal" },
|
||||
{ id: "team", label: "Team" },
|
||||
]
|
||||
return (
|
||||
<div
|
||||
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = mode === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cn(
|
||||
"relative h-8 rounded-full text-center transition-colors",
|
||||
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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserAvatar({
|
||||
url,
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
url: string | null
|
||||
name: string
|
||||
className?: string
|
||||
}) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
|
||||
const hasImage = url && !errored
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full overflow-hidden bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={name}
|
||||
className="size-full object-cover"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[20px] font-semibold text-[#fafafa]">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Perk = { icon: React.ReactNode; title: string; blurb: string }
|
||||
|
||||
function PerksList({ heading, perks }: { heading: string; 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>
|
||||
)
|
||||
}
|
||||
480
apps/web/components/onboarding-brain/step-ingest.tsx
Normal file
480
apps/web/components/onboarding-brain/step-ingest.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPIcon } from "@ui/assets/icons"
|
||||
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { toast } from "sonner"
|
||||
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
|
||||
|
||||
interface Props {
|
||||
mcpUrl: string
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
const modalCardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
|
||||
const inputBevelStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
type AgentCategory = "coding" | "productivity"
|
||||
|
||||
type Agent = {
|
||||
key: string
|
||||
name: string
|
||||
tagline: string
|
||||
category: AgentCategory
|
||||
pluginId?: string
|
||||
}
|
||||
|
||||
const AGENTS: Agent[] = [
|
||||
{
|
||||
key: "cursor",
|
||||
name: "Cursor",
|
||||
tagline: "Persistent context across coding sessions.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "claude-code",
|
||||
name: "Claude Code",
|
||||
tagline: "Memory and decisions across CLI sessions.",
|
||||
category: "coding",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
key: "vscode",
|
||||
name: "VS Code",
|
||||
tagline: "Inline context while you write.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "cline",
|
||||
name: "Cline",
|
||||
tagline: "Agentic dev tasks with your memory.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "codex",
|
||||
name: "Codex",
|
||||
tagline: "OpenAI Codex with persistent memory.",
|
||||
category: "coding",
|
||||
pluginId: "codex",
|
||||
},
|
||||
{
|
||||
key: "gemini-cli",
|
||||
name: "Gemini CLI",
|
||||
tagline: "Gemini in your terminal, brain-aware.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "claude",
|
||||
name: "Claude Desktop",
|
||||
tagline: "Memory across every Claude conversation.",
|
||||
category: "productivity",
|
||||
},
|
||||
{
|
||||
key: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
tagline: "Custom GPT backed by your brain.",
|
||||
category: "productivity",
|
||||
},
|
||||
]
|
||||
|
||||
const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
|
||||
{ id: "coding", label: "Coding" },
|
||||
{ id: "productivity", label: "Productivity" },
|
||||
]
|
||||
|
||||
function agentIcon(agent: Agent) {
|
||||
if (agent.pluginId) {
|
||||
const plugin = PLUGIN_CATALOG[agent.pluginId]
|
||||
if (plugin) return plugin.icon
|
||||
}
|
||||
const file = agent.key === "claude-code" ? "claude" : agent.key
|
||||
return `/mcp-supported-tools/${file}.png`
|
||||
}
|
||||
|
||||
export function StepIngest({ mcpUrl, onContinue }: Props) {
|
||||
const [activeCategory, setActiveCategory] = useState<AgentCategory>("coding")
|
||||
const [selectedKey, setSelectedKey] = useState<string>("cursor")
|
||||
const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
|
||||
|
||||
const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgent && !selectedAgent.pluginId) {
|
||||
setMcpClient(selectedAgent.key)
|
||||
} else {
|
||||
setMcpClient(null)
|
||||
}
|
||||
}, [selectedAgent, setMcpClient])
|
||||
|
||||
const selectAgent = (agent: Agent) => {
|
||||
setSelectedKey(agent.key)
|
||||
}
|
||||
|
||||
const filtered = AGENTS.filter((a) => a.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Use your brain anywhere
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Now plug it into the tools you already use to write code, chat, think.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<McpHero url={mcpUrl} />
|
||||
|
||||
<div className="grid lg:grid-cols-[300px_1fr] gap-4 h-[560px]">
|
||||
<aside
|
||||
className="rounded-[16px] bg-[#1B1F24] p-3 flex flex-col gap-3 overflow-hidden h-full"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<CategoryTabs value={activeCategory} onChange={setActiveCategory} />
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 scrollbar-thin pr-1">
|
||||
{filtered.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.key}
|
||||
agent={agent}
|
||||
active={selectedKey === agent.key}
|
||||
onClick={() => selectAgent(agent)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] p-5 md:p-6 overflow-hidden flex flex-col h-full"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{selectedAgent?.pluginId ? (
|
||||
<PluginSteps pluginId={selectedAgent.pluginId} />
|
||||
) : (
|
||||
<MCPSteps variant="embedded" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpHero({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
toast.success("MCP URL copied")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error("Could not copy")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="size-9 rounded-[10px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<MCPIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
|
||||
Universal MCP URL
|
||||
</p>
|
||||
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
|
||||
{url}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={copy}
|
||||
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy URL"}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginSteps({ pluginId }: { pluginId: string }) {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const steps = plugin.installSteps ?? []
|
||||
return (
|
||||
<div className="h-full overflow-y-auto pr-1 scrollbar-thin">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-10 rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={plugin.icon}
|
||||
alt={plugin.name}
|
||||
width={28}
|
||||
height={28}
|
||||
unoptimized
|
||||
className="size-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-semibold text-[#fafafa] leading-tight">
|
||||
Set up {plugin.name}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#A1A1AA] font-medium mt-0.5">
|
||||
{plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
{plugin.docsUrl && (
|
||||
<a
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[12px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium shrink-0"
|
||||
>
|
||||
Docs ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, i) => (
|
||||
<PluginStep key={step.title} idx={i + 1} step={step} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[10px] border border-[#4BA0FA]/20 bg-[#4BA0FA]/[0.04] p-3 flex items-start gap-2">
|
||||
<div className="text-[11px] text-[#A1A1AA] leading-[1.5] font-medium">
|
||||
Your <span className="text-[#fafafa]">API key</span> is minted in
|
||||
Settings → Integrations → Plugins. Mint it once and paste into the
|
||||
step above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginStep({
|
||||
idx,
|
||||
step,
|
||||
}: {
|
||||
idx: number
|
||||
step: import("@/lib/plugin-catalog").InstallStep
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
if (!step.code) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(step.code)
|
||||
setCopied(true)
|
||||
toast.success("Copied")
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
toast.error("Could not copy")
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0 pt-1">
|
||||
<div className="size-5 rounded-full bg-[#4BA0FA]/15 text-[#4BA0FA] flex items-center justify-center text-[10px] font-semibold">
|
||||
{idx}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-semibold text-[#fafafa]">
|
||||
{step.title}
|
||||
{step.optional && (
|
||||
<span className="ml-2 text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{step.description && (
|
||||
<p className="text-[12px] text-[#A1A1AA] mt-1 leading-[1.5] font-medium">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{step.code && (
|
||||
<div
|
||||
className="mt-2 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] p-3 flex items-start gap-2"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"flex-1 min-w-0 text-[11px] text-[#fafafa] font-mono overflow-x-auto whitespace-pre",
|
||||
step.secret && !revealed && "blur-[3px] select-none",
|
||||
)}
|
||||
>
|
||||
{step.code}
|
||||
</pre>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{step.secret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
|
||||
aria-label={revealed ? "Hide" : "Reveal"}
|
||||
>
|
||||
{revealed ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AgentCategory
|
||||
onChange: (c: AgentCategory) => void
|
||||
}) {
|
||||
const counts: Record<AgentCategory, number> = {
|
||||
coding: 0,
|
||||
productivity: 0,
|
||||
}
|
||||
for (const a of AGENTS) counts[a.category] += 1
|
||||
return (
|
||||
<div className="scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] w-full">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const isActive = value === cat.id
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => onChange(cat.id)}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
|
||||
isActive
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<span className="leading-none">{cat.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold tabular-nums leading-none",
|
||||
isActive ? "text-[#A1A1AA]" : "text-[#525D6E]",
|
||||
)}
|
||||
>
|
||||
{counts[cat.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
agent: Agent
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"w-full text-left flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 transition-colors",
|
||||
active
|
||||
? "bg-white/[0.08] text-[#fafafa]"
|
||||
: "text-[#A1A1AA] hover:bg-white/[0.04] hover:text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
<div className="size-8 rounded-[8px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={agentIcon(agent)}
|
||||
alt={agent.name}
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-medium truncate">{agent.name}</p>
|
||||
<p className="text-[11px] text-[#737373] truncate font-medium">
|
||||
{agent.tagline}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
1252
apps/web/components/onboarding-brain/step-sources.tsx
Normal file
1252
apps/web/components/onboarding-brain/step-sources.tsx
Normal file
File diff suppressed because it is too large
Load diff
331
apps/web/components/onboarding-brain/step-team.tsx
Normal file
331
apps/web/components/onboarding-brain/step-team.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const modalCardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
|
||||
const inputBevelStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
|
||||
|
||||
export interface TeamValues {
|
||||
invites: { email: string; role: "admin" | "member" }[]
|
||||
visibility: "team-private" | "org-shared"
|
||||
suggestChanges: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: "personal" | "team"
|
||||
isScale: boolean
|
||||
inviteDomain: string | null
|
||||
values: TeamValues
|
||||
onChange: (next: TeamValues) => void
|
||||
onContinue: () => void
|
||||
onSkip?: () => void
|
||||
onUpgrade: () => void
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
|
||||
|
||||
export function StepTeam({
|
||||
mode,
|
||||
inviteDomain,
|
||||
values,
|
||||
onChange,
|
||||
onContinue,
|
||||
onSkip,
|
||||
submitting,
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState("")
|
||||
const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
|
||||
|
||||
const addInvites = (text: string) => {
|
||||
const found = text.match(EMAIL_RE) ?? []
|
||||
if (found.length === 0) return
|
||||
const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
|
||||
const next: { email: string; role: "admin" | "member" }[] = []
|
||||
for (const raw of found) {
|
||||
const email = raw.trim().toLowerCase()
|
||||
if (!email || existing.has(email)) continue
|
||||
existing.add(email)
|
||||
next.push({ email, role: "member" })
|
||||
}
|
||||
if (next.length === 0) {
|
||||
setDraft("")
|
||||
return
|
||||
}
|
||||
onChange({ ...values, invites: [...values.invites, ...next] })
|
||||
setDraft("")
|
||||
}
|
||||
|
||||
const removeInvite = (email: string) => {
|
||||
onChange({
|
||||
...values,
|
||||
invites: values.invites.filter((i) => i.email !== email),
|
||||
})
|
||||
}
|
||||
|
||||
const setRole = (email: string, role: "admin" | "member") => {
|
||||
onChange({
|
||||
...values,
|
||||
invites: values.invites.map((i) =>
|
||||
i.email === email ? { ...i, role } : i,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const domainBreakdown = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const inv of values.invites) {
|
||||
const at = inv.email.lastIndexOf("@")
|
||||
if (at < 0) continue
|
||||
const domain = inv.email.slice(at + 1).toLowerCase()
|
||||
counts.set(domain, (counts.get(domain) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1])
|
||||
}, [values.invites])
|
||||
|
||||
if (mode === "personal") {
|
||||
return (
|
||||
<div
|
||||
className="max-w-xl mx-auto rounded-[22px] bg-[#1B1F24] p-10 text-center"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center mx-auto"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<Users className="size-5 text-[#737373]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa] mt-5",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Going solo — for now
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] mt-2 leading-[1.5] font-medium">
|
||||
You're in Personal mode, so there's no team step. Switch to Team in
|
||||
the top bar anytime to invite others.
|
||||
</p>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const count = values.invites.length
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<Users className="size-5 text-[#fafafa]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Invite your team
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
|
||||
A brain gets sharper as more people contribute. You can also do
|
||||
this later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
addInvites(draft)
|
||||
}}
|
||||
className="mt-6 flex gap-2"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const pasted = e.clipboardData.getData("text")
|
||||
if (pasted && EMAIL_RE.test(pasted)) {
|
||||
e.preventDefault()
|
||||
addInvites(pasted)
|
||||
}
|
||||
}}
|
||||
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
|
||||
className={cn(inputClass, "pl-10")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="insideOut"
|
||||
disabled={!draft.trim()}
|
||||
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
|
||||
Paste multiple emails at once — we'll split them for you.
|
||||
</p>
|
||||
|
||||
{count === 0 ? (
|
||||
<div className="mt-6 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-5 py-8 text-center">
|
||||
<p className="text-[13px] text-[#737373] font-medium">
|
||||
No invites yet.
|
||||
</p>
|
||||
<p className="text-[12px] text-[#525D6E] font-medium mt-1">
|
||||
Try{" "}
|
||||
<span className="text-[#A1A1AA]">alex@{domainOrFallback}</span>,{" "}
|
||||
<span className="text-[#A1A1AA]">sam@{domainOrFallback}</span>,
|
||||
etc.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-6 flex items-center justify-between gap-3 px-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
|
||||
{count} invite{count === 1 ? "" : "s"}
|
||||
</p>
|
||||
{domainBreakdown.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-[#737373] font-medium">
|
||||
{domainBreakdown.slice(0, 3).map(([d, n]) => (
|
||||
<span
|
||||
key={d}
|
||||
className="px-2 py-0.5 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)]"
|
||||
>
|
||||
{d} · {n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
|
||||
{values.invites.map((inv) => (
|
||||
<div
|
||||
key={inv.email}
|
||||
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
|
||||
<span className="text-[11px] font-semibold text-[#A1A1AA] uppercase">
|
||||
{(inv.email[0] ?? "?").toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[13px] text-[#fafafa] flex-1 font-medium truncate">
|
||||
{inv.email}
|
||||
</span>
|
||||
<Select
|
||||
value={inv.role}
|
||||
onValueChange={(r) =>
|
||||
setRole(inv.email, r as "admin" | "member")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-7 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
|
||||
<SelectItem
|
||||
value="member"
|
||||
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
|
||||
>
|
||||
Member
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="admin"
|
||||
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
|
||||
>
|
||||
Admin
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInvite(inv.email)}
|
||||
className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
|
||||
aria-label={`Remove ${inv.email}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
131
apps/web/components/onboarding-brain/types.ts
Normal file
131
apps/web/components/onboarding-brain/types.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
export type BrainMode = "personal" | "team"
|
||||
|
||||
export type BrainStep = "about" | "sources" | "ingest" | "team"
|
||||
|
||||
export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
|
||||
|
||||
export const BRAIN_STEP_LABELS: Record<BrainStep, string> = {
|
||||
about: "About",
|
||||
sources: "Tools",
|
||||
ingest: "Flows",
|
||||
team: "Team",
|
||||
}
|
||||
|
||||
const FREE_EMAIL_DOMAINS = new Set([
|
||||
"gmail.com",
|
||||
"googlemail.com",
|
||||
"yahoo.com",
|
||||
"yahoo.co.uk",
|
||||
"yahoo.co.in",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"live.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com",
|
||||
"aol.com",
|
||||
"protonmail.com",
|
||||
"proton.me",
|
||||
"pm.me",
|
||||
"fastmail.com",
|
||||
"zoho.com",
|
||||
"yandex.com",
|
||||
"yandex.ru",
|
||||
"mail.com",
|
||||
"qq.com",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"naver.com",
|
||||
"duck.com",
|
||||
])
|
||||
|
||||
export function detectModeFromEmail(
|
||||
email: string | undefined | null,
|
||||
): BrainMode {
|
||||
if (!email) return "personal"
|
||||
const at = email.lastIndexOf("@")
|
||||
if (at < 0) return "personal"
|
||||
const domain = email
|
||||
.slice(at + 1)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
if (!domain) return "personal"
|
||||
if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
|
||||
return "team"
|
||||
}
|
||||
|
||||
export function workspaceNameFromEmail(
|
||||
email: string | undefined | null,
|
||||
): string {
|
||||
if (!email) return ""
|
||||
const at = email.lastIndexOf("@")
|
||||
if (at < 0) return ""
|
||||
const domain = email.slice(at + 1).toLowerCase()
|
||||
const root = domain.split(".")[0] ?? ""
|
||||
if (!root) return ""
|
||||
return root.charAt(0).toUpperCase() + root.slice(1)
|
||||
}
|
||||
|
||||
export function workspaceDomainFromEmail(
|
||||
email: string | undefined | null,
|
||||
): string | null {
|
||||
if (!email) return null
|
||||
const at = email.lastIndexOf("@")
|
||||
if (at < 0) return null
|
||||
const domain = email
|
||||
.slice(at + 1)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
return domain || null
|
||||
}
|
||||
|
||||
export type BrainMetadata = {
|
||||
brainOnboardingVersion?: "v1"
|
||||
brainOnboardingComplete?: boolean
|
||||
brainMode?: BrainMode
|
||||
brainWorkspaceName?: string
|
||||
brainWorkspaceDomain?: string | null
|
||||
brainAbout?: string
|
||||
brainContainerTag?: string
|
||||
brainSources?: {
|
||||
drive?: { status: "connected" | "pending" | "skipped" }
|
||||
gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
|
||||
notion?: { status: "connected" | "pending" | "skipped" }
|
||||
granola?: { status: "waitlist" | "skipped" }
|
||||
}
|
||||
brainInvites?: { email: string; role: "admin" | "member" }[]
|
||||
brainPermissions?: {
|
||||
visibility: "team-private" | "org-shared"
|
||||
suggestChanges: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function generateOrgSlug(name: string): string {
|
||||
const base =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "org"
|
||||
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
|
||||
}
|
||||
|
||||
export function generateUsername(name: string): string {
|
||||
const base =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/(^_|_$)/g, "") || "user"
|
||||
return `${base}${Math.floor(100000 + Math.random() * 900000)}`
|
||||
}
|
||||
|
||||
export function containerTagFromWorkspace(
|
||||
name: string,
|
||||
mode: BrainMode,
|
||||
): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
|
||||
return mode === "team" ? `${slug}-brain` : `${slug}-personal`
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useMemo, useEffect, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
|
@ -1128,14 +1128,14 @@ export function SelectSpacesModal({
|
|||
>
|
||||
<div className="flex items-start justify-between gap-4 px-4 pt-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Space
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
|
||||
{isBulkDeleteMode
|
||||
? "Choose spaces to permanently delete"
|
||||
|
|
|
|||
|
|
@ -40,13 +40,18 @@ import { toast } from "sonner"
|
|||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { PopoverAnchor } from "@ui/components/popover"
|
||||
import { OrgContext } from "@/components/settings/org-context"
|
||||
import { OrgPlanBadge } from "@/components/org-plan-badge"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { FileText, Layers, Plug, Search } from "lucide-react"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -127,6 +132,10 @@ function isPendingInvitation(invitation: {
|
|||
|
||||
export default function Account() {
|
||||
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id)
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
|
||||
const [inviteEmail, setInviteEmail] = useState("")
|
||||
const [inviteRole, setInviteRole] = useState<InviteRole>("member")
|
||||
|
|
@ -202,6 +211,11 @@ export default function Account() {
|
|||
[org?.invitations],
|
||||
)
|
||||
|
||||
const showTeamCard =
|
||||
!canManageTeam ||
|
||||
pendingInvitations.length > 0 ||
|
||||
(org?.members?.length ?? 0) > 1
|
||||
|
||||
const resetInviteForm = () => {
|
||||
setInviteEmail("")
|
||||
setInviteRole("member")
|
||||
|
|
@ -372,30 +386,29 @@ export default function Account() {
|
|||
: "—"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<section id="profile-details" className="flex flex-col gap-4">
|
||||
<SectionTitle>Profile Details</SectionTitle>
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<section id="profile-details">
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Avatar + Name/Email */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative size-11 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
||||
<Avatar className="size-full">
|
||||
<AvatarImage
|
||||
src={user?.image ?? ""}
|
||||
alt={user?.name ?? "User"}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-xl">
|
||||
<AvatarFallback className="bg-transparent text-white text-base">
|
||||
{user?.name?.charAt(0) ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
"truncate font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{user?.name ?? "—"}
|
||||
|
|
@ -403,28 +416,32 @@ export default function Account() {
|
|||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"truncate text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{user?.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<OrgPlanBadge plan={currentPlan} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p
|
||||
{/* Fields */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Organization
|
||||
</p>
|
||||
</span>
|
||||
{isEditingOrgName ? (
|
||||
<form
|
||||
onSubmit={handleOrgNameSubmit}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[360px]"
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[320px]"
|
||||
>
|
||||
<input
|
||||
value={orgNameDraft}
|
||||
|
|
@ -433,51 +450,47 @@ export default function Account() {
|
|||
maxLength={80}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-3 text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
||||
"h-8 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-2.5 text-[13px] font-medium tracking-[-0.13px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
||||
)}
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateOrgNameMutation.isPending ||
|
||||
!orgNameDraft.trim() ||
|
||||
orgNameDraft.trim() === org?.name
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateOrgNameMutation.isPending ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
aria-label="Cancel organization name edit"
|
||||
title="Cancel"
|
||||
onClick={() => {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(false)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateOrgNameMutation.isPending ||
|
||||
!orgNameDraft.trim() ||
|
||||
orgNameDraft.trim() === org?.name
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-7 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateOrgNameMutation.isPending ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
aria-label="Cancel organization name edit"
|
||||
title="Cancel"
|
||||
onClick={() => {
|
||||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(false)
|
||||
}}
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"truncate font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{org?.name ?? "Personal"}
|
||||
|
|
@ -491,49 +504,110 @@ export default function Account() {
|
|||
setOrgNameDraft(org?.name ?? "")
|
||||
setIsEditingOrgName(true)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5",
|
||||
)}
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:bg-white/5 hover:text-[#FAFAFA]"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Member since
|
||||
</p>
|
||||
<p
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{memberSince}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
<SectionTitle>Overview</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
label: "Memories",
|
||||
value: orgSummary?.documentCount,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Spaces",
|
||||
value: orgSummary?.containerTagCount,
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
label: "Connections",
|
||||
value: orgSummary?.activeConnectors,
|
||||
icon: Plug,
|
||||
},
|
||||
{ label: "Searches", value: searchesUsed, icon: Search },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-col gap-3 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#0D121A] text-[#737373] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[24px] font-semibold leading-none tracking-[-0.5px] text-[#FAFAFA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{typeof value === "number" ? value.toLocaleString() : "—"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OrgContext />
|
||||
|
||||
<section id="team-members" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-2">
|
||||
<section id="team-members" className="flex flex-col gap-4 px-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SectionTitle>Team members</SectionTitle>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<SectionTitle>Team members</SectionTitle>
|
||||
{(org?.members?.length ?? 0) > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tabular-nums text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{org?.members?.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invite people into {org?.name ?? "your organization"} and manage
|
||||
|
|
@ -541,17 +615,6 @@ export default function Account() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{(org?.members?.length ?? 0) > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{org?.members?.length}{" "}
|
||||
{org?.members?.length === 1 ? "member" : "members"}
|
||||
</span>
|
||||
)}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -568,234 +631,209 @@ export default function Account() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-5">
|
||||
{!canManageTeam && (
|
||||
<div className="flex items-center gap-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
{showTeamCard && (
|
||||
<div className="rounded-[14px] bg-[#14161A] p-2 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{!canManageTeam && (
|
||||
<div className="flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
||||
<div className="size-8 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12.5px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Only organization owners and admins can invite teammates or
|
||||
change roles.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Only organization owners and admins can invite teammates or
|
||||
change roles.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
Pending invitations
|
||||
</p>
|
||||
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<li
|
||||
key={invitation.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
|
||||
>
|
||||
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
|
||||
<Mail className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{invitation.email}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invited as {formatRole(invitation.role)}
|
||||
</p>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
onClick={() =>
|
||||
cancelInvitationMutation.mutate(invitation.id)
|
||||
}
|
||||
className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
|
||||
aria-label={`Cancel invitation for ${invitation.email}`}
|
||||
title="Cancel invitation"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org?.members && org.members.length > 0 ? (
|
||||
<ul className="flex flex-col">
|
||||
{[...org.members]
|
||||
.sort((a, b) => {
|
||||
const rolePriority = (r: string) =>
|
||||
r === "owner" ? 0 : r === "admin" ? 1 : 2
|
||||
const diff =
|
||||
rolePriority(a.role.toLowerCase()) -
|
||||
rolePriority(b.role.toLowerCase())
|
||||
if (diff !== 0) return diff
|
||||
return (a.user?.name ?? "").localeCompare(
|
||||
b.user?.name ?? "",
|
||||
)
|
||||
})
|
||||
.map((m, idx) => {
|
||||
const isYou = m.userId === user?.id
|
||||
const memberRole = m.role.toLowerCase()
|
||||
const name = m.user?.name ?? m.user?.email ?? "Unknown"
|
||||
const canEditMember =
|
||||
canManageTeam && !isYou && memberRole !== "owner"
|
||||
return (
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
Pending invitations
|
||||
</p>
|
||||
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5",
|
||||
idx > 0 && "border-t border-white/[0.04]",
|
||||
)}
|
||||
key={invitation.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
|
||||
>
|
||||
<Avatar className="size-9 shrink-0 bg-[#0D121A]">
|
||||
<AvatarImage
|
||||
src={m.user?.image ?? ""}
|
||||
alt={name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
|
||||
{(name.charAt(0) || "U").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
|
||||
)}
|
||||
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
|
||||
<Mail className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{invitation.email}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invited as {formatRole(invitation.role)}
|
||||
</p>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
onClick={() =>
|
||||
cancelInvitationMutation.mutate(invitation.id)
|
||||
}
|
||||
className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
|
||||
aria-label={`Cancel invitation for ${invitation.email}`}
|
||||
title="Cancel invitation"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isYou && (
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org?.members && org.members.length > 0 && (
|
||||
<ul className="flex flex-col">
|
||||
{[...org.members]
|
||||
.sort((a, b) => {
|
||||
const rolePriority = (r: string) =>
|
||||
r === "owner" ? 0 : r === "admin" ? 1 : 2
|
||||
const diff =
|
||||
rolePriority(a.role.toLowerCase()) -
|
||||
rolePriority(b.role.toLowerCase())
|
||||
if (diff !== 0) return diff
|
||||
return (a.user?.name ?? "").localeCompare(
|
||||
b.user?.name ?? "",
|
||||
)
|
||||
})
|
||||
.map((m) => {
|
||||
const isYou = m.userId === user?.id
|
||||
const memberRole = m.role.toLowerCase()
|
||||
const name = m.user?.name ?? m.user?.email ?? "Unknown"
|
||||
const canEditMember =
|
||||
canManageTeam && !isYou && memberRole !== "owner"
|
||||
return (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 rounded-[10px] px-3 py-2.5 hover:bg-white/[0.02]"
|
||||
>
|
||||
<Avatar className="size-8 shrink-0 bg-[#0D121A]">
|
||||
<AvatarImage
|
||||
src={m.user?.image ?? ""}
|
||||
alt={name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
|
||||
{(name.charAt(0) || "U").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
|
||||
)}
|
||||
>
|
||||
You
|
||||
{name}
|
||||
</span>
|
||||
{isYou && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.user?.email && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
|
||||
)}
|
||||
>
|
||||
{m.user.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.user?.email && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
|
||||
)}
|
||||
{canEditMember ? (
|
||||
<Select
|
||||
value={memberRole}
|
||||
onValueChange={(value) => {
|
||||
if (value === memberRole) return
|
||||
updateMemberRoleMutation.mutate({
|
||||
memberId: m.id,
|
||||
role: value as InviteRole,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{m.user.email}
|
||||
</span>
|
||||
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<RolePill role={m.role} />
|
||||
)}
|
||||
</div>
|
||||
{canEditMember ? (
|
||||
<Select
|
||||
value={memberRole}
|
||||
onValueChange={(value) => {
|
||||
if (value === memberRole) return
|
||||
updateMemberRoleMutation.mutate({
|
||||
memberId: m.id,
|
||||
role: value as InviteRole,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<RolePill role={m.role} />
|
||||
)}
|
||||
{canEditMember && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
|
||||
aria-label={`Team actions for ${name}`}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-[#C73B1B] focus:text-[#C73B1B]"
|
||||
onSelect={() =>
|
||||
removeMemberMutation.mutate(m.id)
|
||||
}
|
||||
disabled={
|
||||
removeMemberMutation.isPending || !isOwner
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Just you for now
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invite teammates to start collaborating.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canEditMember && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
|
||||
aria-label={`Team actions for ${name}`}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-[#C73B1B] focus:text-[#C73B1B]"
|
||||
onSelect={() =>
|
||||
removeMemberMutation.mutate(m.id)
|
||||
}
|
||||
disabled={
|
||||
removeMemberMutation.isPending || !isOwner
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
|
|
@ -1148,7 +1186,7 @@ export default function Account() {
|
|||
onClick={() => setInviteDialogOpen(false)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white",
|
||||
"h-9 rounded-full px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-white",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -11,8 +11,15 @@ import {
|
|||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@ui/components/dialog"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { usePostHog } from "@lib/posthog"
|
||||
import {
|
||||
CANCEL_REASONS,
|
||||
cancelReasonNeedsDetail,
|
||||
type CancelReasonValue,
|
||||
} from "./cancel-reasons"
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
|
|
@ -31,6 +38,38 @@ import { toast } from "sonner"
|
|||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
|
||||
|
||||
function GoogleMeetIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 87.5 72"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="#00832d"
|
||||
d="M49.5 36l8.53 9.75 11.47 7.33 2-17.02-2-16.64-11.69 6.44z"
|
||||
/>
|
||||
<path
|
||||
fill="#0066da"
|
||||
d="M0 51.5V66c0 3.315 2.685 6 6 6h14.5l3-10.96-3-9.54-9.95-3z"
|
||||
/>
|
||||
<path fill="#e94235" d="M20.5 0L0 20.5l10.55 3 9.95-3 2.95-9.41z" />
|
||||
<path fill="#2684fc" d="M20.5 20.5H0v31h20.5z" />
|
||||
<path
|
||||
fill="#00ac47"
|
||||
d="M82.6 8.68L69.5 19.42v33.66l13.16 10.79c1.97 1.54 4.85.135 4.85-2.37V11c0-2.535-2.945-3.925-4.91-2.32zM49.5 36v15.5h-29V72h43c3.315 0 6-2.685 6-6V53.08z"
|
||||
/>
|
||||
<path
|
||||
fill="#ffba00"
|
||||
d="M63.5 0h-43v20.5h29V36l20-16.57V6c0-3.315-2.685-6-6-6z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const CREDIT_FEATURE_ID = "usd_credits"
|
||||
const TOP_UP_PLAN_ID = "credits_topup"
|
||||
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
|
||||
|
|
@ -436,12 +475,16 @@ function getInvoiceProductLabel(productId: string | undefined): string {
|
|||
export default function Billing() {
|
||||
const queryClient = useQueryClient()
|
||||
const { user, org } = useAuth()
|
||||
const autumn = useCustomer({ expand: ["payment_method"] })
|
||||
const autumn = useCustomer()
|
||||
const posthog = usePostHog()
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isResuming, setIsResuming] = useState(false)
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
||||
const [cancelConfirmText, setCancelConfirmText] = useState("")
|
||||
const [cancelReason, setCancelReason] = useState<CancelReasonValue | null>(
|
||||
null,
|
||||
)
|
||||
const [cancelDetail, setCancelDetail] = useState("")
|
||||
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
|
||||
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
|
||||
const [planPage, setPlanPage] = useState<0 | 1>(0)
|
||||
|
|
@ -578,16 +621,11 @@ export default function Billing() {
|
|||
? (`api_${currentPlan}` as const)
|
||||
: null
|
||||
|
||||
const currentPlanCard = [...PLAN_CARDS, ...ADVANCED_PLAN_CARDS].find(
|
||||
(p) => p.id === currentPlan,
|
||||
)
|
||||
const cancelLossItems = currentPlanCard
|
||||
? [
|
||||
`${currentPlanCard.credits}/mo included credits`,
|
||||
...currentPlanCard.features.filter((f) => !/credit/i.test(f)),
|
||||
]
|
||||
: []
|
||||
const canConfirmCancel = cancelConfirmText.trim().toUpperCase() === "CANCEL"
|
||||
const cancelNeedsDetail =
|
||||
cancelReason != null && cancelReasonNeedsDetail(cancelReason)
|
||||
const canConfirmCancel =
|
||||
cancelReason != null &&
|
||||
(!cancelNeedsDetail || cancelDetail.trim().length > 0)
|
||||
|
||||
const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
|
||||
const isPlanCanceling = canceledSub != null
|
||||
|
|
@ -627,6 +665,11 @@ export default function Billing() {
|
|||
}
|
||||
}
|
||||
|
||||
const resetCancelForm = () => {
|
||||
setCancelReason(null)
|
||||
setCancelDetail("")
|
||||
}
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
if (!cancellablePlanId) return
|
||||
setIsCancelling(true)
|
||||
|
|
@ -635,9 +678,18 @@ export default function Billing() {
|
|||
planId: cancellablePlanId,
|
||||
cancelAction: "cancel_end_of_cycle",
|
||||
})
|
||||
if (posthog?.__loaded) {
|
||||
posthog.capture("subscription_cancelled", {
|
||||
reason: cancelReason,
|
||||
reason_detail: cancelDetail.trim() || null,
|
||||
plan: currentPlan,
|
||||
plan_id: cancellablePlanId,
|
||||
surface: "nova",
|
||||
})
|
||||
}
|
||||
autumn.refetch?.()
|
||||
setIsCancelDialogOpen(false)
|
||||
setCancelConfirmText("")
|
||||
resetCancelForm()
|
||||
toast.success(
|
||||
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
|
||||
)
|
||||
|
|
@ -934,7 +986,7 @@ export default function Billing() {
|
|||
open={isCancelDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsCancelDialogOpen(open)
|
||||
if (!open) setCancelConfirmText("")
|
||||
if (!open) resetCancelForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
|
|
@ -950,7 +1002,7 @@ export default function Billing() {
|
|||
</DialogTrigger>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[min(420px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)]"
|
||||
className="w-[min(760px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)] sm:max-w-[760px]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -985,74 +1037,177 @@ export default function Billing() {
|
|||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
{cancelLossItems.length > 0 ? (
|
||||
<div className="mt-4 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3.5">
|
||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
|
||||
You'll lose
|
||||
</p>
|
||||
<ul className="mt-2.5 flex flex-col gap-1.5">
|
||||
{cancelLossItems.map((item) => (
|
||||
<li
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
|
||||
key={item}
|
||||
|
||||
<div className="@container">
|
||||
<div className="grid gap-4 @xl:grid-cols-[1fr_auto_1fr] @xl:gap-5">
|
||||
<div className="relative flex flex-col overflow-hidden rounded-[14px] border border-[#1E4FA0]/25 bg-gradient-to-b from-[#121826] to-[#0C0E13] p-5">
|
||||
<Logo className="pointer-events-none absolute -right-8 -bottom-10 z-0 h-56 w-auto opacity-[0.03]" />
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GoogleMeetIcon className="h-[18px] w-auto" />
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[15px] font-semibold tracking-[-0.01em] text-white",
|
||||
)}
|
||||
>
|
||||
Talk to us first
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-3 text-[13px] leading-relaxed text-[#AEBCD8]",
|
||||
)}
|
||||
>
|
||||
<X className="mt-0.5 size-3.5 shrink-0 text-[#C73B1B]" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
Give us 15 minutes before you go — a quick call
|
||||
with the team where we'll:
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-col gap-2.5">
|
||||
{[
|
||||
"Fix anything that's not working for you",
|
||||
"Help you integrate Supermemory the right way",
|
||||
"Get a discount to stay",
|
||||
].map((benefit) => (
|
||||
<li
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-start gap-2 text-[13px] leading-snug text-[#D4DEEE]",
|
||||
)}
|
||||
key={benefit}
|
||||
>
|
||||
<Check
|
||||
className="mt-0.5 size-[15px] shrink-0 text-[#3FD37E]"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={BOOK_CALL_HREF}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => {
|
||||
if (posthog?.__loaded) {
|
||||
posthog.capture(
|
||||
"cancel_book_call_clicked",
|
||||
{
|
||||
plan: currentPlan,
|
||||
surface: "nova",
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"group mt-auto inline-flex h-10 items-center justify-center gap-1.5 rounded-[10px] bg-[#0054AD] px-4 text-[13px] font-semibold text-white transition-colors hover:bg-[#0B65C9]",
|
||||
)}
|
||||
>
|
||||
Book a call
|
||||
<ChevronRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[#737373]">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col rounded-[14px] border border-white/10 bg-gradient-to-b from-[#12151B] to-[#0C0E13] p-5">
|
||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
|
||||
Why are you leaving?
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-0.5">
|
||||
{CANCEL_REASONS.map((option) => {
|
||||
const selected = cancelReason === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCancelReason(option.value)
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-2.5 rounded-[8px] px-2 py-1.5 text-left text-[13px] transition-colors",
|
||||
selected
|
||||
? "text-[#FAFAFA]"
|
||||
: "text-[#A3A3A3] hover:bg-white/[0.03]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
selected
|
||||
? "border-[#0054AD]"
|
||||
: "border-white/25",
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
<span className="size-2 rounded-full bg-[#0054AD]" />
|
||||
) : null}
|
||||
</span>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{cancelReason !== null ? (
|
||||
<div className="mt-3">
|
||||
<label
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#A3A3A3]",
|
||||
)}
|
||||
htmlFor="cancel-detail"
|
||||
>
|
||||
{cancelNeedsDetail
|
||||
? "Tell us more (required)"
|
||||
: "Anything else? (optional)"}
|
||||
</label>
|
||||
<textarea
|
||||
className="mt-2 min-h-[72px] w-full resize-none rounded-[9px] border border-white/10 bg-[#0D121A] px-3 py-2 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
|
||||
id="cancel-detail"
|
||||
onChange={(e) =>
|
||||
setCancelDetail(e.target.value)
|
||||
}
|
||||
placeholder="What could we have done better?"
|
||||
value={cancelDetail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto pt-5">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Keep plan
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCancelSubscription()}
|
||||
disabled={isCancelling || !canConfirmCancel}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1.5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-[#C73B1B]/25 bg-[#290F0A] text-[13px] font-semibold text-[#E5604A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{isCancelling ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Cancel subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#A3A3A3]",
|
||||
)}
|
||||
htmlFor="cancel-confirm"
|
||||
>
|
||||
Type{" "}
|
||||
<span className="font-semibold text-[#FAFAFA]">
|
||||
CANCEL
|
||||
</span>{" "}
|
||||
to confirm
|
||||
</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
className="mt-2 h-9 w-full rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
|
||||
id="cancel-confirm"
|
||||
onChange={(e) => setCancelConfirmText(e.target.value)}
|
||||
placeholder="CANCEL"
|
||||
type="text"
|
||||
value={cancelConfirmText}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 px-3 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Keep plan
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCancelSubscription()}
|
||||
disabled={isCancelling || !canConfirmCancel}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{isCancelling ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Cancel subscription
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
19
apps/web/components/settings/cancel-reasons.ts
Normal file
19
apps/web/components/settings/cancel-reasons.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const CANCEL_REASONS = [
|
||||
{ value: "too_expensive", label: "Too expensive" },
|
||||
{ value: "missing_features", label: "Missing features I need" },
|
||||
{ value: "switching", label: "Found a better alternative" },
|
||||
{ value: "not_using", label: "Not using it enough" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const
|
||||
|
||||
export type CancelReasonValue = (typeof CANCEL_REASONS)[number]["value"]
|
||||
|
||||
const NEEDS_DETAIL: CancelReasonValue[] = [
|
||||
"missing_features",
|
||||
"switching",
|
||||
"other",
|
||||
]
|
||||
|
||||
export function cancelReasonNeedsDetail(value: CancelReasonValue): boolean {
|
||||
return NEEDS_DETAIL.includes(value)
|
||||
}
|
||||
|
|
@ -23,8 +23,8 @@ import { useQueryState } from "nuqs"
|
|||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { ConnectAIModal } from "@/components/connect-ai-modal"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
|
|
@ -421,7 +421,7 @@ export default function ConnectionsMCP() {
|
|||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const [removeDialog, setRemoveDialog] = useState<{
|
||||
open: boolean
|
||||
connection: Connection | null
|
||||
|
|
@ -709,14 +709,14 @@ export default function ConnectionsMCP() {
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<ConnectAIModal open={mcpModalOpen} onOpenChange={setMcpModalOpen}>
|
||||
<PillButton onClick={() => setMcpModalOpen(true)}>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect your AI to Supermemory
|
||||
</span>
|
||||
</PillButton>
|
||||
</ConnectAIModal>
|
||||
<PillButton
|
||||
onClick={() => router.push("/?view=integrations&cat=ai-clients")}
|
||||
>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect your AI to Supermemory
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
</ConnectionsCard>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,17 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { LoaderIcon, Settings, X } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { LoaderIcon } from "lucide-react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
|
||||
type ContextTemplate = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const SURFACE_SHADOW =
|
||||
"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 CONTEXT_TEMPLATES: ContextTemplate[] = [
|
||||
{
|
||||
id: "personal-general",
|
||||
label: "General Personal Assistant",
|
||||
description:
|
||||
"Remember preferences, routines, relationships, plans, and life context.",
|
||||
prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context.
|
||||
|
||||
EXTRACT:
|
||||
- Preferences: "prefers morning meetings", "allergic to peanuts"
|
||||
- Routines: "works out every Tuesday and Thursday"
|
||||
- Relationships: "Sarah is their manager", "lives with roommate Jake"
|
||||
- Plans: "planning a trip to Japan in March"
|
||||
- Life events: "moved to Austin last month", "started a new job"
|
||||
|
||||
SKIP:
|
||||
- Generic assistant suggestions the user did not confirm
|
||||
- Pleasantries and small talk without factual content
|
||||
- Repeated scheduling details already captured`,
|
||||
},
|
||||
{
|
||||
id: "personal-productivity",
|
||||
label: "Productivity Assistant",
|
||||
description:
|
||||
"Focus on tasks, decisions, deadlines, workflows, and blockers.",
|
||||
prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context.
|
||||
|
||||
EXTRACT:
|
||||
- Action items: "needs to send proposal to client by Friday"
|
||||
- Decisions: "team decided to use Figma for design handoff"
|
||||
- Deadlines: "Q3 review due September 15th"
|
||||
- Workflows: "deploys happen every Wednesday via CI pipeline"
|
||||
- Blockers: "waiting on legal approval before launch"
|
||||
|
||||
SKIP:
|
||||
- Meeting filler ("let's circle back", "good point")
|
||||
- Status updates that repeat previously captured information
|
||||
- Agenda items with no outcome or decision`,
|
||||
},
|
||||
]
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: "default" | "ghost" | "primary"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full px-4 text-[13px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "ghost"
|
||||
? "px-3 font-medium text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
|
||||
: "border border-transparent bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgContext() {
|
||||
const { data: settings, isLoading, isError } = useOrgSettings()
|
||||
const updateSettings = useUpdateOrgSettings()
|
||||
const [confirmDialog, setConfirmDialog] = useState<
|
||||
"enable" | "disable" | null
|
||||
>(null)
|
||||
const [isManaging, setIsManaging] = useState(false)
|
||||
const [prompt, setPrompt] = useState("")
|
||||
|
||||
const enabled = settings?.shouldLLMFilter ?? false
|
||||
|
|
@ -132,255 +33,95 @@ export function OrgContext() {
|
|||
setPrompt(settings?.filterPrompt ?? "")
|
||||
}, [settings?.filterPrompt])
|
||||
|
||||
const selectedTemplateId = useMemo(() => {
|
||||
const normalized = prompt.trim()
|
||||
return CONTEXT_TEMPLATES.find(
|
||||
(template) => template.prompt.trim() === normalized,
|
||||
)?.id
|
||||
}, [prompt])
|
||||
|
||||
const handleConfirmToggle = () => {
|
||||
const newEnabled = confirmDialog === "enable"
|
||||
updateSettings.mutate(
|
||||
newEnabled
|
||||
? { shouldLLMFilter: true }
|
||||
: { shouldLLMFilter: false, filterPrompt: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfirmDialog(null)
|
||||
if (!newEnabled) {
|
||||
setIsManaging(false)
|
||||
setPrompt("")
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
const handleToggle = (next: boolean) => {
|
||||
updateSettings.mutate({ shouldLLMFilter: next })
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: prompt.trim() ? prompt.trim() : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsManaging(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setPrompt(savedPrompt)
|
||||
setIsManaging(false)
|
||||
updateSettings.mutate({
|
||||
shouldLLMFilter: true,
|
||||
filterPrompt: prompt.trim() ? prompt.trim() : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="organization-context" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
<section id="organization-context" className="flex flex-col gap-3 px-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Guide how Supermemory processes and remembers your content.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
onClick={() => handleToggle(!enabled)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
dmSansClassName(),
|
||||
"inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
enabled
|
||||
? "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
|
||||
: "bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
Guide how Nova processes and remembers your content.
|
||||
</p>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</button>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
{enabled ? "Context is enabled" : "Context is disabled"}
|
||||
</p>
|
||||
<p className="text-[13px] leading-snug text-[#737373]">
|
||||
{enabled
|
||||
? "New content will use your guidance when Nova creates memories."
|
||||
: "Turn this on to tell Nova what matters most when it learns."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
variant={enabled ? "ghost" : "primary"}
|
||||
>
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</PillButton>
|
||||
{enabled && (
|
||||
<PillButton
|
||||
onClick={() => setIsManaging(true)}
|
||||
|
||||
{enabled && (
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Tell Nova what to focus on, extract, and skip when turning your content into memories..."
|
||||
maxLength={750}
|
||||
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#737373] tabular-nums">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(savedPrompt)}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
MANAGE
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && !isManaging && savedPrompt && (
|
||||
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
|
||||
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{savedPrompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && !isManaging && !savedPrompt && (
|
||||
<p className="text-[13px] italic text-[#737373]">
|
||||
No organization context configured.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManaging(true)}
|
||||
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && isManaging && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-medium text-[#FAFAFA]">
|
||||
What should Nova focus on?
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..."
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none",
|
||||
)}
|
||||
maxLength={750}
|
||||
/>
|
||||
<span className="self-end text-[11px] text-[#737373]">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{CONTEXT_TEMPLATES.map((template) => {
|
||||
const isSelected = selectedTemplateId === template.id
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => setPrompt(template.prompt)}
|
||||
className={cn(
|
||||
"rounded-[14px] border p-4 text-left transition-colors cursor-pointer",
|
||||
"bg-[#14161A] hover:bg-[#121820]",
|
||||
isSelected
|
||||
? "border-white/[0.16] bg-[#171B22]"
|
||||
: "border-white/[0.08]",
|
||||
)}
|
||||
>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">
|
||||
{template.label}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
|
||||
{template.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
|
||||
<PillButton onClick={handleCancel} variant="ghost">
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || updateSettings.isPending}
|
||||
variant="primary"
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
SAVE
|
||||
</PillButton>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog
|
||||
open={!!confirmDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDialog(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow: SURFACE_SHADOW,
|
||||
}}
|
||||
className={cn(
|
||||
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Enable Organization Context?"
|
||||
: "Disable Organization Context?"}
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Nova will use your guidance when processing new content across this organization."
|
||||
: "Nova will stop using this guidance for new content, and the saved context will be cleared."}
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
variant="ghost"
|
||||
>
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleConfirmToggle}
|
||||
disabled={updateSettings.isPending}
|
||||
variant="primary"
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
|
||||
</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
538
apps/web/components/settings/settings-content.tsx
Normal file
538
apps/web/components/settings/settings-content.tsx
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
"use client"
|
||||
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Sun,
|
||||
LoaderIcon,
|
||||
User as UserIcon,
|
||||
Zap,
|
||||
HelpCircle,
|
||||
CreditCard,
|
||||
ShieldAlert,
|
||||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher"
|
||||
|
||||
export const TABS = [
|
||||
"account",
|
||||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
||||
type NavItem = {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
label: "Account",
|
||||
description: "Your profile and organization",
|
||||
icon: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
? (cleaned as SettingsTab)
|
||||
: "account"
|
||||
}
|
||||
|
||||
function IdentityCard({ displayName }: { displayName: string }) {
|
||||
const firstName = displayName?.split(" ")[0] || ""
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsContent({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
className,
|
||||
showIdentity = true,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
className?: string
|
||||
showIdentity?: boolean
|
||||
}) {
|
||||
const { user, org } = useAuth()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
|
||||
const [resetConfirmation, setResetConfirmation] = useState("")
|
||||
const resetOrganization = useResetOrganization()
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
user?.name ||
|
||||
user?.email?.split("@")[0] ||
|
||||
""
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const handleIntegrations = () => {
|
||||
void router.push("/?view=integrations")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteEmailConfirm !== user?.email) return
|
||||
deleteUserAccount.mutate(
|
||||
{ confirmation: deleteEmailConfirm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteEmailConfirm("")
|
||||
router.push("/login")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex flex-col md:flex-row gap-4 md:gap-5", className)}
|
||||
>
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[248px] md:flex-col shrink-0">
|
||||
{showIdentity && !isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={displayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<SettingsOrgSwitcher />
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isExternal = item.id === "integrations"
|
||||
const isActive = !isExternal && activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isExternal ? handleIntegrations() : onTabChange(item.id)
|
||||
}
|
||||
className={cn(
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
{isExternal && (
|
||||
<ArrowUpRight className="ml-auto size-4 shrink-0 text-white/30 transition-colors group-hover:text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">Reset data</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
<p className="text-muted-foreground p-4">
|
||||
Something went wrong loading this section.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => setResetConfirmation(e.target.value)}
|
||||
placeholder={confirmText || "Your name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsResetDialogOpen(false)
|
||||
setResetConfirmation("")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{resetOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
|
||||
placeholder={user?.email ?? "your@email.com"}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
142
apps/web/components/settings/settings-modal.tsx
Normal file
142
apps/web/components/settings/settings-modal.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
SettingsContent,
|
||||
TABS,
|
||||
type SettingsTab,
|
||||
} from "@/components/settings/settings-content"
|
||||
|
||||
const SETTINGS_PARAM = "settings"
|
||||
|
||||
/** Integrations is a link-out, not a modal tab, so it falls back to account. */
|
||||
function parseTab(value: string | null): SettingsTab {
|
||||
if (
|
||||
value &&
|
||||
value !== "integrations" &&
|
||||
TABS.includes(value as SettingsTab)
|
||||
) {
|
||||
return value as SettingsTab
|
||||
}
|
||||
return "account"
|
||||
}
|
||||
|
||||
type SettingsModalContextValue = {
|
||||
open: boolean
|
||||
openSettings: (tab?: SettingsTab) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const SettingsModalContext = createContext<SettingsModalContextValue | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
export function SettingsModalProvider({ children }: { children: ReactNode }) {
|
||||
const [param, setParam] = useQueryState(SETTINGS_PARAM)
|
||||
const open = param !== null
|
||||
const tab = parseTab(param)
|
||||
|
||||
const openSettings = useCallback(
|
||||
(next?: SettingsTab) => {
|
||||
setParam(next ?? "account")
|
||||
},
|
||||
[setParam],
|
||||
)
|
||||
|
||||
const close = useCallback(() => setParam(null), [setParam])
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(next: SettingsTab) => {
|
||||
setParam(next)
|
||||
analytics.settingsTabChanged({ tab: next })
|
||||
},
|
||||
[setParam],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ open, openSettings, close }),
|
||||
[open, openSettings, close],
|
||||
)
|
||||
|
||||
return (
|
||||
<SettingsModalContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setParam(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "calc(100vw - 1rem)",
|
||||
maxWidth: "1160px",
|
||||
height: "min(840px, 90vh)",
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
"flex-col min-w-0 border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 px-5 md:px-7 pt-5 shrink-0">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[18px] text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] text-[#737373] transition-colors hover:text-white cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<SettingsContent
|
||||
activeTab={tab}
|
||||
onTabChange={handleTabChange}
|
||||
showIdentity={false}
|
||||
className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSettingsModal() {
|
||||
const ctx = useContext(SettingsModalContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSettingsModal must be used within a SettingsModalProvider",
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
262
apps/web/components/settings/settings-org-switcher.tsx
Normal file
262
apps/web/components/settings/settings-org-switcher.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
LoaderIcon,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { useOrgSummaries } from "@/hooks/use-org-summaries"
|
||||
import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage"
|
||||
|
||||
const SURFACE_SHADOW =
|
||||
"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"
|
||||
|
||||
function generateOrgSlug(name: string): string {
|
||||
const base =
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "org"
|
||||
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
|
||||
}
|
||||
|
||||
export function SettingsOrgSwitcher() {
|
||||
const { org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
const { data: orgSummaries } = useOrgSummaries()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [switchingId, setSwitchingId] = useState<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
for (const summary of orgSummaries ?? []) {
|
||||
map.set(summary.orgId, summary.plan)
|
||||
}
|
||||
return map
|
||||
}, [orgSummaries])
|
||||
|
||||
const activeOrgPlan = org?.id
|
||||
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
|
||||
: currentPlan
|
||||
|
||||
const sortedOrgs = useMemo(
|
||||
() =>
|
||||
[...(organizations ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations],
|
||||
)
|
||||
|
||||
const handleSwitch = async (slug: string, id: string) => {
|
||||
if (id === org?.id) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setSwitchingId(id)
|
||||
try {
|
||||
await setActiveOrg(slug)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error("Failed to switch organization:", error)
|
||||
setSwitchingId(null)
|
||||
toast.error("Failed to switch organization")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = createName.trim()
|
||||
if (!name || creating) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const result = await authClient.organization.create({
|
||||
name,
|
||||
slug: generateOrgSlug(name),
|
||||
metadata: { signupSource: "consumer" },
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message ?? "Failed to create organization")
|
||||
}
|
||||
await setActiveOrg(result.data?.slug ?? "")
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setCreating(false)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create organization",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex w-full items-center gap-2 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-2 transition-colors cursor-pointer hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-white">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(360px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[220px] overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{sortedOrgs.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() => handleSwitch(organization.slug, organization.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
|
||||
isCurrent ? "bg-white/5" : "hover:bg-white/5 cursor-pointer",
|
||||
"disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left text-[#A3A3A3] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Plus className="size-4 shrink-0" />
|
||||
<span className="text-[13.5px] font-medium">
|
||||
Create organization
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(next) => {
|
||||
setCreateOpen(next)
|
||||
if (!next) setCreateName("")
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{ boxShadow: SURFACE_SHADOW }}
|
||||
className={cn(
|
||||
"sm:max-w-[420px] border border-white/[0.12] bg-[#1B1F24] p-5 gap-0 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Create organization
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]">
|
||||
A separate workspace with its own memories, connections, and
|
||||
members.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate()
|
||||
}}
|
||||
placeholder="Organization name"
|
||||
maxLength={80}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#4BA0FA]/50 transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!createName.trim() || creating}
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{creating ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-[15px]" />
|
||||
)}
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
"use client"
|
||||
|
||||
import { Brain, X } from "lucide-react"
|
||||
import { Brain, Loader, X } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { useSpaceProfile } from "@/hooks/use-space-profile"
|
||||
import {
|
||||
useSpaceContext,
|
||||
useUpdateSpaceContext,
|
||||
} from "@/hooks/use-space-context"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
type SpaceProfilePanelProps = {
|
||||
|
|
@ -87,6 +92,85 @@ function ProfileSection({ label, items }: { label: string; items: string[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SpaceContextEditor({ containerTag }: { containerTag: string }) {
|
||||
const { data, isLoading } = useSpaceContext(containerTag)
|
||||
const update = useUpdateSpaceContext()
|
||||
const [value, setValue] = useState("")
|
||||
const saved = data?.entityContext ?? ""
|
||||
|
||||
useEffect(() => {
|
||||
setValue(data?.entityContext ?? "")
|
||||
}, [data?.entityContext])
|
||||
|
||||
const dirty = value.trim() !== saved.trim()
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate({
|
||||
containerTag,
|
||||
entityContext: value.trim() ? value.trim() : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-[14px] border border-white/[0.08] bg-[#14161A] p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
What to remember
|
||||
</span>
|
||||
<span className="text-[11px] leading-relaxed text-[#737373]">
|
||||
Tell Nova what matters in this space — it shapes which memories get
|
||||
extracted.
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
disabled={isLoading || update.isPending}
|
||||
placeholder="e.g. This space tracks Acme's billing project — decisions, owners, and deadlines."
|
||||
maxLength={750}
|
||||
className="min-h-[80px] w-full resize-y rounded-[10px] border border-white/[0.08] bg-[#0D121A] px-3 py-2.5 text-[12px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none disabled:opacity-60"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#737373] tabular-nums">
|
||||
{value.length}/750
|
||||
</span>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue(saved)}
|
||||
disabled={update.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={update.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{update.isPending && <Loader className="size-3 animate-spin" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpaceProfileContent({
|
||||
containerTag,
|
||||
onClose,
|
||||
|
|
@ -125,20 +209,23 @@ export function SpaceProfileContent({
|
|||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
<SpaceContextEditor containerTag={containerTag} />
|
||||
<div className="mt-5">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
|
||||
Failed to load space profile.
|
||||
</p>
|
||||
) : totalCount === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<ProfileSection label="Key Facts" items={keyFacts} />
|
||||
<ProfileSection label="Recent Context" items={recentContext} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import { cn } from "@lib/utils"
|
|||
import { $fetch } from "@lib/api"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
|
||||
import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { EditSpaceModal } from "./edit-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
import { SpaceGlyph } from "./space-glyph"
|
||||
import { useProjectMutations } from "@/hooks/use-project-mutations"
|
||||
|
|
@ -54,6 +55,7 @@ export interface SpaceSelectorProps {
|
|||
compact?: boolean
|
||||
includeAuto?: boolean
|
||||
hideCount?: boolean
|
||||
enableEdit?: boolean
|
||||
}
|
||||
|
||||
const triggerVariants = {
|
||||
|
|
@ -114,9 +116,11 @@ export function SpaceSelector({
|
|||
compact = false,
|
||||
includeAuto = false,
|
||||
hideCount = false,
|
||||
enableEdit = false,
|
||||
}: SpaceSelectorProps) {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [recents, setRecents] = useState<string[]>([])
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
|
|
@ -231,6 +235,12 @@ export function SpaceSelector({
|
|||
}
|
||||
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
|
||||
|
||||
const canEditCurrent =
|
||||
enableEdit &&
|
||||
!displayInfo.isAuto &&
|
||||
!displayInfo.plugin &&
|
||||
!displayInfo.isOwnSpace
|
||||
|
||||
const pushRecent = useCallback((tag: string) => {
|
||||
setRecents((prev) => {
|
||||
const next = [tag, ...prev.filter((t) => t !== tag)].slice(0, RECENTS_MAX)
|
||||
|
|
@ -371,85 +381,115 @@ export function SpaceSelector({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="group relative inline-flex min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSelectSpacesModal(true)}
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open selector.`
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
|
||||
triggerVariants[variant],
|
||||
variant === "default" &&
|
||||
compact &&
|
||||
"h-9 min-h-9 gap-1.5 px-2.5",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
canEditCurrent && "transition-opacity group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
{displayInfo.isAuto ? (
|
||||
<AutoSpaceIcon size={compact ? 16 : 18} />
|
||||
) : displayInfo.isOwnSpace ? (
|
||||
<NovaOrb
|
||||
size={compact ? 14 : 16}
|
||||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : displayInfo.plugin ? (
|
||||
displayInfo.plugin.iconSrc ? (
|
||||
<Image
|
||||
src={displayInfo.plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
"shrink-0 rounded-[3px]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(displayInfo.plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<SpaceGlyph
|
||||
emoji={displayInfo.emoji}
|
||||
size={compact ? 16 : 18}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact &&
|
||||
!hideCount &&
|
||||
spaceCountData !== undefined &&
|
||||
spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Switch space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canEditCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSelectSpacesModal(true)}
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open selector.`
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setShowEditDialog(true)
|
||||
}}
|
||||
aria-label={`Edit ${displayInfo.name}`}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
|
||||
triggerVariants[variant],
|
||||
variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
"pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md text-[#A1A1AA] opacity-0 transition-opacity hover:text-white group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
compact ? "left-2.5 size-4" : "left-3 size-[18px]",
|
||||
)}
|
||||
>
|
||||
{displayInfo.isAuto ? (
|
||||
<AutoSpaceIcon size={compact ? 16 : 18} />
|
||||
) : displayInfo.isOwnSpace ? (
|
||||
<NovaOrb
|
||||
size={compact ? 14 : 16}
|
||||
className="shrink-0 blur-[0.45px]!"
|
||||
/>
|
||||
) : displayInfo.plugin ? (
|
||||
displayInfo.plugin.iconSrc ? (
|
||||
<Image
|
||||
src={displayInfo.plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
"shrink-0 rounded-[3px]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(displayInfo.plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
{!compact &&
|
||||
!hideCount &&
|
||||
spaceCountData !== undefined &&
|
||||
spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className="size-3.5 shrink-0 text-[#737373]"
|
||||
aria-hidden
|
||||
/>
|
||||
<Pencil className={compact ? "size-3" : "size-3.5"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Switch space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddSpaceModal
|
||||
isOpen={showCreateDialog}
|
||||
|
|
@ -475,6 +515,16 @@ export function SpaceSelector({
|
|||
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||
/>
|
||||
|
||||
{canEditCurrent && (
|
||||
<EditSpaceModal
|
||||
containerTag={activeTag}
|
||||
currentName={displayInfo.name}
|
||||
currentEmoji={displayInfo.emoji ?? "📁"}
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onOpenChange={(open: boolean) => {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function SuperLoader({
|
|||
<output
|
||||
aria-label={label}
|
||||
className={`inline-flex flex-row items-center gap-2 ${className}`}
|
||||
style={{ width: size + 10 }}
|
||||
style={{ minWidth: size + 10 }}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -87,7 +87,7 @@ export function SuperLoader({
|
|||
</motion.svg>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium text-slate-500"
|
||||
className="whitespace-nowrap text-xs font-medium text-slate-500"
|
||||
style={{ fontSize: size * 0.25 }}
|
||||
>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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"
|
||||
|
||||
export function UserProfileMenu({
|
||||
className,
|
||||
|
|
@ -29,6 +30,7 @@ export function UserProfileMenu({
|
|||
}) {
|
||||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
|
||||
|
|
@ -144,7 +146,7 @@ export function UserProfileMenu({
|
|||
</div>
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push("/settings")}
|
||||
onClick={() => openSettings()}
|
||||
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"
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
|
|
|
|||
|
|
@ -190,6 +190,16 @@
|
|||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Loose lists wrap item content in <p> whose top margin detaches the bullet */
|
||||
.chat-markdown-content li > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Model spams `---` between every section; headings already separate them */
|
||||
.chat-markdown-content hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shadow-inside-out {
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
|
|
|
|||
|
|
@ -7,28 +7,26 @@ import {
|
|||
} from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
|
||||
function fetchErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === "string") return err
|
||||
if (err && typeof err === "object") {
|
||||
const e = err as { error?: unknown; message?: unknown }
|
||||
if (typeof e.error === "string") return e.error
|
||||
if (typeof e.message === "string") return e.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface DocumentWithId {
|
||||
id?: string
|
||||
customId?: string | null
|
||||
}
|
||||
|
||||
interface DocumentsQueryData {
|
||||
documents: DocumentWithId[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
type InfiniteQueryData = {
|
||||
pages: DocumentsResponse[]
|
||||
pageParams: number[]
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsOptions {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
|
@ -212,6 +210,8 @@ function restoreQueriesFromSnapshot(
|
|||
}
|
||||
|
||||
const FILE_UPLOAD_CONCURRENCY = 3
|
||||
const fullDocumentQueryKey = (documentId: string) =>
|
||||
["document-full", documentId] as const
|
||||
|
||||
export type FileUploadEntry = { id: string; file: File }
|
||||
|
||||
|
|
@ -226,7 +226,23 @@ export function useDocumentMutations({
|
|||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
|
||||
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
|
||||
const defaultEntityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
|
||||
|
||||
// Skip when the space has its own context — sending one would overwrite the stored value.
|
||||
const resolveEntityContext = async (
|
||||
project: string,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const settings = await queryClient.fetchQuery({
|
||||
queryKey: spaceSettingsKey(project),
|
||||
queryFn: () => fetchSpaceSettings(project),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
return settings?.entityContext ? undefined : defaultEntityContext
|
||||
} catch {
|
||||
return defaultEntityContext
|
||||
}
|
||||
}
|
||||
|
||||
const noteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
|
|
@ -236,11 +252,12 @@ export function useDocumentMutations({
|
|||
content: string
|
||||
project: string
|
||||
}) => {
|
||||
const entityContext = await resolveEntityContext(project)
|
||||
const response = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content,
|
||||
containerTags: [project],
|
||||
entityContext,
|
||||
...(entityContext !== undefined ? { entityContext } : {}),
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
})
|
||||
|
|
@ -300,11 +317,12 @@ export function useDocumentMutations({
|
|||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: async ({ url, project }: { url: string; project: string }) => {
|
||||
const entityContext = await resolveEntityContext(project)
|
||||
const response = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext,
|
||||
...(entityContext !== undefined ? { entityContext } : {}),
|
||||
metadata: { sm_source: "consumer" },
|
||||
},
|
||||
})
|
||||
|
|
@ -376,12 +394,15 @@ export function useDocumentMutations({
|
|||
}): Promise<FileUploadBatchResult> => {
|
||||
const applyMeta = fileEntries.length === 1
|
||||
const failures: { id: string; message: string }[] = []
|
||||
const entityContext = await resolveEntityContext(project)
|
||||
|
||||
const uploadOne = async (entry: FileUploadEntry) => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", entry.file)
|
||||
formData.append("containerTags", JSON.stringify([project]))
|
||||
formData.append("entityContext", entityContext)
|
||||
if (entityContext !== undefined) {
|
||||
formData.append("entityContext", entityContext)
|
||||
}
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
const response = await fetch(
|
||||
|
|
@ -544,6 +565,10 @@ export function useDocumentMutations({
|
|||
onSuccess: (_data, variables) => {
|
||||
analytics.documentEdited({ document_id: variables.documentId })
|
||||
toast.success("Document saved successfully!")
|
||||
queryClient.setQueryData(
|
||||
fullDocumentQueryKey(variables.documentId),
|
||||
variables.content,
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -560,7 +585,9 @@ export function useDocumentMutations({
|
|||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete document")
|
||||
throw new Error(
|
||||
fetchErrorMessage(response.error, "Failed to delete document"),
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
|
|
@ -584,6 +611,10 @@ export function useDocumentMutations({
|
|||
onSuccess: (_data, variables) => {
|
||||
analytics.documentDeleted({ document_id: variables.documentId })
|
||||
toast.success("Document deleted successfully!")
|
||||
queryClient.removeQueries({
|
||||
queryKey: fullDocumentQueryKey(variables.documentId),
|
||||
exact: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
onClose?.()
|
||||
},
|
||||
|
|
@ -596,7 +627,9 @@ export function useDocumentMutations({
|
|||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to delete documents")
|
||||
throw new Error(
|
||||
fetchErrorMessage(response.error, "Failed to delete documents"),
|
||||
)
|
||||
}
|
||||
|
||||
return response.data
|
||||
|
|
@ -623,6 +656,12 @@ export function useDocumentMutations({
|
|||
toast.success(
|
||||
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
|
||||
)
|
||||
for (const documentId of variables.documentIds) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: fullDocumentQueryKey(documentId),
|
||||
exact: true,
|
||||
})
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
29
apps/web/hooks/use-full-document.ts
Normal file
29
apps/web/hooks/use-full-document.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
// List endpoint truncates `content` to ~2000 chars; fetch the full doc by id for the viewer.
|
||||
export function useFullDocumentContent(
|
||||
documentId: string | null | undefined,
|
||||
enabled: boolean,
|
||||
): string | null {
|
||||
const isReal = !!documentId && !documentId.startsWith("temp-")
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["document-full", documentId],
|
||||
enabled: enabled && isReal,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@get/documents/:id", {
|
||||
params: { id: documentId as string },
|
||||
disableValidation: true,
|
||||
})
|
||||
if (res.error) {
|
||||
throw new Error(res.error?.message || "Failed to fetch document")
|
||||
}
|
||||
const doc = res.data as { content?: string | null } | null
|
||||
return typeof doc?.content === "string" ? doc.content : null
|
||||
},
|
||||
})
|
||||
|
||||
return data ?? null
|
||||
}
|
||||
115
apps/web/hooks/use-space-context.ts
Normal file
115
apps/web/hooks/use-space-context.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client"
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
export type SpaceSettings = {
|
||||
containerTag: string
|
||||
name: string | null
|
||||
entityContext: string | null
|
||||
}
|
||||
|
||||
export const spaceSettingsKey = (containerTag: string) =>
|
||||
["container-tag-settings", containerTag] as const
|
||||
|
||||
export async function fetchSpaceSettings(
|
||||
containerTag: string,
|
||||
): Promise<SpaceSettings | null> {
|
||||
const response = await $fetch(`@get/container-tags/${containerTag}`, {
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to load space settings")
|
||||
}
|
||||
const data = response.data as Partial<SpaceSettings> | null
|
||||
if (!data) return null
|
||||
return {
|
||||
containerTag,
|
||||
name: data.name ?? null,
|
||||
entityContext: data.entityContext ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSpaceContext(containerTag: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: spaceSettingsKey(containerTag),
|
||||
queryFn: () => fetchSpaceSettings(containerTag),
|
||||
enabled: enabled && !!containerTag,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSpace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
containerTag,
|
||||
name,
|
||||
entityContext,
|
||||
}: {
|
||||
containerTag: string
|
||||
name?: string
|
||||
entityContext?: string | null
|
||||
}) => {
|
||||
const body: { name?: string; entityContext?: string | null } = {}
|
||||
if (name !== undefined) body.name = name
|
||||
if (entityContext !== undefined) body.entityContext = entityContext
|
||||
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
|
||||
body,
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(response.error?.message || "Failed to save space")
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (_data, { containerTag }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: spaceSettingsKey(containerTag),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
|
||||
toast.success("Space updated")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save space",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSpaceContext() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
containerTag,
|
||||
entityContext,
|
||||
}: {
|
||||
containerTag: string
|
||||
entityContext: string | null
|
||||
}) => {
|
||||
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
|
||||
body: { entityContext },
|
||||
})
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
response.error?.message || "Failed to save space context",
|
||||
)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (_data, { containerTag }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: spaceSettingsKey(containerTag),
|
||||
})
|
||||
toast.success("Space context saved")
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save space context",
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type SpaceProfile = {
|
||||
|
|
@ -7,6 +6,16 @@ export type SpaceProfile = {
|
|||
dynamic: string[]
|
||||
}
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type SpaceProfileResponse = {
|
||||
profile?: {
|
||||
static?: string[] | null
|
||||
dynamic?: string[] | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export function useSpaceProfile(containerTag: string) {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id ?? ""
|
||||
|
|
@ -14,21 +23,32 @@ export function useSpaceProfile(containerTag: string) {
|
|||
return useQuery({
|
||||
queryKey: ["space-profile", orgId, containerTag],
|
||||
queryFn: async (): Promise<SpaceProfile> => {
|
||||
const response = await $fetch(
|
||||
"@get/container-tags/:containerTag/profile",
|
||||
{
|
||||
params: { containerTag },
|
||||
const response = await fetch(`${API_BASE}/v4/profile`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
)
|
||||
if (response.error) {
|
||||
body: JSON.stringify({ containerTag }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
throw new Error(
|
||||
response.error.message || "Failed to load space profile",
|
||||
body.message ?? body.error ?? "Failed to load space profile",
|
||||
)
|
||||
}
|
||||
const profile = response.data.profile
|
||||
|
||||
const data = (await response.json()) as SpaceProfileResponse
|
||||
const profile = data.profile
|
||||
|
||||
return {
|
||||
static: profile.static ?? [],
|
||||
dynamic: profile.dynamic ?? [],
|
||||
static: profile?.static ?? [],
|
||||
dynamic: profile?.dynamic ?? [],
|
||||
}
|
||||
},
|
||||
enabled: !!orgId && !!containerTag,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue