mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge origin/main into docs restructure branch
Resolve conflicts in docs navigation and self-hosting configuration: keep the restructured Getting Started pages, add the new embeddings doc under Deployment, and point configuration at the dedicated embeddings page from main. Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
commit
83c6fe4dbe
132 changed files with 8364 additions and 1712 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint
|
|||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
name: Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
9
.github/workflows/claude-auto-fix-ci.yml
vendored
9
.github/workflows/claude-auto-fix-ci.yml
vendored
|
|
@ -19,6 +19,7 @@ jobs:
|
|||
github.event.workflow_run.conclusion == 'failure' &&
|
||||
github.event.workflow_run.pull_requests[0]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
|
@ -41,18 +42,22 @@ jobs:
|
|||
- name: Get CI failure details
|
||||
id: failure_details
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
with:
|
||||
script: |
|
||||
const runId = Number(process.env.RUN_ID);
|
||||
|
||||
const run = await github.rest.actions.getWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure');
|
||||
|
|
|
|||
9
.github/workflows/claude-code-review.yml
vendored
9
.github/workflows/claude-code-review.yml
vendored
|
|
@ -4,14 +4,23 @@ on:
|
|||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Fork PRs run with a read-only token and no secrets/OIDC id-token, so the
|
||||
# Claude action can never authenticate there and the job always fails. Skip
|
||||
# it for forks so those PRs report a clean skipped check instead of a red X.
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'graphite-app[bot]' &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
|
|
|||
33
.github/workflows/claude.yml
vendored
33
.github/workflows/claude.yml
vendored
|
|
@ -13,11 +13,36 @@ on:
|
|||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review_comment' &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@claude') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/agent-framework-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-ai-sdk.yml
vendored
5
.github/workflows/publish-ai-sdk.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/ai-sdk/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/cartesia-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-memory-graph.yml
vendored
5
.github/workflows/publish-memory-graph.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/memory-graph/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/openai-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/pipecat-sdk-python/pyproject.toml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
5
.github/workflows/publish-tools.yml
vendored
5
.github/workflows/publish-tools.yml
vendored
|
|
@ -7,9 +7,14 @@ on:
|
|||
paths:
|
||||
- "packages/tools/package.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
|
|
|||
|
|
@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed:
|
|||
# You'll need to add your API keys and database URLs
|
||||
```
|
||||
|
||||
4. **Change proxy for local development**
|
||||
|
||||
Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`):
|
||||
|
||||
```ts
|
||||
if (url.hostname === "localhost") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
5. **Start the Development Server**
|
||||
4. **Start the Development Server**
|
||||
|
||||
```bash
|
||||
bun run dev:local
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -28,6 +28,12 @@
|
|||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>#1 on every major AI memory benchmark — <a href="https://github.com/xiaowu0162/LongMemEval">LongMemEval</a>, <a href="https://github.com/snap-research/locomo">LoCoMo</a>, and <a href="https://github.com/Salesforce/ConvoMem">ConvoMem</a>.</strong><br/>
|
||||
<strong>95% Recall@15 with a 99.4% context reduction · ~50ms user profiles.</strong><br/>
|
||||
<a href="https://supermemory.ai/research">Read the research →</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory.
|
||||
|
|
@ -351,11 +357,12 @@ const client = new Supermemory({
|
|||
```
|
||||
|
||||
- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
|
||||
- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud.
|
||||
- **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine.
|
||||
- **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move.
|
||||
- **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`.
|
||||
|
||||
Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, configuration, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
|
||||
Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -365,10 +372,14 @@ Supermemory is state of the art across all major AI memory benchmarks:
|
|||
|
||||
| Benchmark | What it measures | Result |
|
||||
|---|---|---|
|
||||
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **81.6% — #1** |
|
||||
| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | Long-term memory across sessions with knowledge updates | **#1** |
|
||||
| **[LoCoMo](https://github.com/snap-research/locomo)** | Fact recall across extended conversations (single-hop, multi-hop, temporal, adversarial) | **#1** |
|
||||
| **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | Personalization and preference learning | **#1** |
|
||||
|
||||
On LongMemEval, supermemory reaches **95% Recall@15 while adding only ~720 tokens of context — a 99.4% context reduction** (99.6% at @10, 99.8% at @5). Recall by category: Knowledge Updates 99%, Assistant recall 100%, User recall 97%, Multi-session 93%, Temporal Reasoning 91%, Preference 90%.
|
||||
|
||||
We also built the **Supermemory Filesystem (SMFS)**, which uses **3.0× fewer tokens on Claude** (24M vs 72M) and **1.75× fewer on Codex** across the 110-question xAFS benchmark. See the full write-ups on our [research page](https://supermemory.ai/research).
|
||||
|
||||
We also built **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)** — an open-source framework for standardized, reproducible benchmarks of memory providers. Compare Supermemory, Mem0, Zep, and others head-to-head:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -21,11 +21,55 @@ import type {
|
|||
MemoryPayload,
|
||||
} from "../utils/types"
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
chatgpt: "ChatGPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
t3: "T3 Chat",
|
||||
twitter: "X / Twitter",
|
||||
}
|
||||
|
||||
function normalizePlatform(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
function inferPlatformFromActionSource(
|
||||
actionSource: string,
|
||||
): string | undefined {
|
||||
const source = actionSource.toLowerCase()
|
||||
if (source.includes("chatgpt")) return "chatgpt"
|
||||
if (source.includes("claude")) return "claude"
|
||||
if (source.includes("gemini")) return "gemini"
|
||||
if (source.includes("t3")) return "t3"
|
||||
if (source.includes("twitter") || source.includes("x_")) return "twitter"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferPlatformFromUrl(url?: string): string | undefined {
|
||||
if (!url) return undefined
|
||||
try {
|
||||
const hostname = new URL(url).hostname
|
||||
if (hostname === "chatgpt.com" || hostname === "chat.openai.com") {
|
||||
return "chatgpt"
|
||||
}
|
||||
if (hostname === "claude.ai") return "claude"
|
||||
if (hostname === "gemini.google.com") return "gemini"
|
||||
if (hostname === "t3.chat") return "t3"
|
||||
if (hostname === "x.com" || hostname === "twitter.com") return "twitter"
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
let twitterImporter: TwitterImporter | null = null
|
||||
|
||||
browser.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === "install") {
|
||||
if (details.reason === "install" || details.reason === "update") {
|
||||
await trackEvent("extension_installed", {
|
||||
reason: details.reason,
|
||||
version: browser.runtime.getManifest().version,
|
||||
|
|
@ -107,11 +151,33 @@ export default defineBackground(() => {
|
|||
content = data?.url || ""
|
||||
}
|
||||
|
||||
const platform =
|
||||
normalizePlatform(data.sourcePlatform) ||
|
||||
inferPlatformFromUrl(data.url) ||
|
||||
inferPlatformFromActionSource(actionSource)
|
||||
const platformLabel = platform
|
||||
? data.sourcePlatformLabel || PLATFORM_LABELS[platform] || platform
|
||||
: undefined
|
||||
|
||||
const metadata: MemoryPayload["metadata"] = {
|
||||
sm_source: "consumer",
|
||||
sm_origin: "browser_extension",
|
||||
sm_origin_action: actionSource,
|
||||
website_url: data.url,
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
metadata.sm_origin_platform = platform
|
||||
}
|
||||
|
||||
if (platformLabel) {
|
||||
metadata.sm_origin_platform_label = platformLabel
|
||||
}
|
||||
|
||||
if (data.sourceSurface) {
|
||||
metadata.sm_origin_surface = data.sourceSurface
|
||||
}
|
||||
|
||||
if (data.ogImage) {
|
||||
metadata.website_og_image = data.ogImage
|
||||
}
|
||||
|
|
@ -148,7 +214,17 @@ export default defineBackground(() => {
|
|||
eventSource: string,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
|
||||
try {
|
||||
const responseData = await searchMemories(data)
|
||||
let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT
|
||||
try {
|
||||
const defaultProject = await getDefaultProject()
|
||||
if (defaultProject?.containerTag) {
|
||||
containerTag = defaultProject.containerTag
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to get default project, using fallback:", error)
|
||||
}
|
||||
|
||||
const responseData = await searchMemories(data, containerTag)
|
||||
const response = responseData as {
|
||||
results?: Array<{ memory?: string }>
|
||||
}
|
||||
|
|
@ -156,7 +232,6 @@ export default defineBackground(() => {
|
|||
response.results?.forEach((result, index) => {
|
||||
memories.push(`${index + 1}. ${result.memory} \n`)
|
||||
})
|
||||
console.log("Memories:", memories)
|
||||
await trackEvent(eventSource)
|
||||
return { success: true, data: memories }
|
||||
} catch (error) {
|
||||
|
|
@ -236,12 +311,12 @@ export default defineBackground(() => {
|
|||
platform: string
|
||||
source: string
|
||||
}
|
||||
console.log("=== PROMPT CAPTURED ===")
|
||||
console.log(messageData)
|
||||
console.log("========================")
|
||||
|
||||
const memoryData: MemoryData = {
|
||||
content: messageData.prompt,
|
||||
url: messageData.source,
|
||||
sourcePlatform: messageData.platform,
|
||||
sourceSurface: "prompt_capture",
|
||||
}
|
||||
|
||||
const result = await saveMemoryToSupermemory(
|
||||
|
|
|
|||
|
|
@ -13,18 +13,37 @@ import {
|
|||
createChatGPTInputBarElement,
|
||||
DOMUtils,
|
||||
} from "../../utils/ui-components"
|
||||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
let chatGPTDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let chatGPTRouteObserver: MutationObserver | null = null
|
||||
let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let chatGPTObserverThrottle: NodeJS.Timeout | null = null
|
||||
const CHATGPT_DEBUG = false
|
||||
const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]"
|
||||
|
||||
export function initializeChatGPT() {
|
||||
debugChatGPT("initializeChatGPT called", {
|
||||
host: window.location.hostname,
|
||||
href: window.location.href,
|
||||
})
|
||||
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
|
||||
debugChatGPT("not on ChatGPT domain, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
if (document.body.hasAttribute("data-chatgpt-initialized")) {
|
||||
debugChatGPT("already initialized")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +58,18 @@ export function initializeChatGPT() {
|
|||
setupChatGPTRouteChangeDetection()
|
||||
|
||||
document.body.setAttribute("data-chatgpt-initialized", "true")
|
||||
debugChatGPT("initialized listeners")
|
||||
}
|
||||
|
||||
function debugChatGPT(message: string, data?: unknown) {
|
||||
if (!CHATGPT_DEBUG) return
|
||||
|
||||
if (data === undefined) {
|
||||
console.log(CHATGPT_LOG_PREFIX, message)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(CHATGPT_LOG_PREFIX, message, data)
|
||||
}
|
||||
|
||||
function setupChatGPTRouteChangeDetection() {
|
||||
|
|
@ -58,7 +89,7 @@ function setupChatGPTRouteChangeDetection() {
|
|||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
console.log("ChatGPT route changed, re-adding supermemory elements")
|
||||
debugChatGPT("route changed, re-adding supermemory elements", currentUrl)
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToMemoriesDialog()
|
||||
addSaveChatGPTElementBeforeComposerBtn()
|
||||
|
|
@ -83,8 +114,10 @@ function setupChatGPTRouteChangeDetection() {
|
|||
if (
|
||||
element.querySelector?.("#prompt-textarea") ||
|
||||
element.querySelector?.("button.composer-btn") ||
|
||||
element.querySelector?.("button") ||
|
||||
element.querySelector?.('[role="dialog"]') ||
|
||||
element.matches?.("#prompt-textarea") ||
|
||||
element.matches?.("button") ||
|
||||
element.id === "prompt-textarea"
|
||||
) {
|
||||
shouldRecheck = true
|
||||
|
|
@ -98,6 +131,7 @@ function setupChatGPTRouteChangeDetection() {
|
|||
chatGPTObserverThrottle = setTimeout(() => {
|
||||
try {
|
||||
chatGPTObserverThrottle = null
|
||||
debugChatGPT("DOM changed near composer, rechecking UI")
|
||||
addSupermemoryButtonToMemoriesDialog()
|
||||
addSaveChatGPTElementBeforeComposerBtn()
|
||||
setupChatGPTAutoFetch()
|
||||
|
|
@ -124,6 +158,8 @@ function setupChatGPTRouteChangeDetection() {
|
|||
|
||||
async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
const userQuery =
|
||||
document.getElementById("prompt-textarea")?.textContent || ""
|
||||
|
||||
|
|
@ -138,7 +174,15 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
updateChatGPTIconFeedback("Searching memories...", iconElement)
|
||||
if (isAutoSearch) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
showLoadingSuggestion("chatgpt", promptElement)
|
||||
}
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
} else {
|
||||
updateChatGPTIconFeedback("Searching memories...", iconElement)
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
|
|
@ -159,24 +203,39 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
if (response?.success && response?.data) {
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
console.log(
|
||||
"Prompt element dataset:",
|
||||
promptElement.dataset.supermemories,
|
||||
const memoryText = showMemorySuggestion(
|
||||
"chatgpt",
|
||||
promptElement,
|
||||
response.data,
|
||||
)
|
||||
debugChatGPT("memory suggestion rendered", {
|
||||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = response.data
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
|
||||
updateChatGPTIconFeedback("Included Memories", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
} else {
|
||||
updateChatGPTIconFeedback("Included Memories", iconElement)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"ChatGPT prompt element not found after successful memory fetch",
|
||||
)
|
||||
updateChatGPTIconFeedback("Memories found", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
} else {
|
||||
updateChatGPTIconFeedback("Memories found", iconElement)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("No memories found or API response invalid")
|
||||
updateChatGPTIconFeedback("No memories found", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
updateChatGPTIconFeedback("No memories found", iconElement)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting related memories:", error)
|
||||
|
|
@ -185,7 +244,13 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)[0] as HTMLElement
|
||||
if (icon) {
|
||||
updateChatGPTIconFeedback("Error fetching memories", icon)
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
) {
|
||||
setMemoryMarkerStatus(icon, "error")
|
||||
} else {
|
||||
updateChatGPTIconFeedback("Error fetching memories", icon)
|
||||
}
|
||||
}
|
||||
} catch (feedbackError) {
|
||||
console.error("Failed to update error feedback:", feedbackError)
|
||||
|
|
@ -218,7 +283,7 @@ function addSupermemoryButtonToMemoriesDialog() {
|
|||
supermemoryButton.id = "supermemory-save-button"
|
||||
supermemoryButton.className = "btn relative btn-primary-outline mr-2"
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
|
||||
supermemoryButton.innerHTML = `
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
|
|
@ -278,11 +343,16 @@ async function saveMemoriesToSupermemory() {
|
|||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
data: {
|
||||
html: combinedContent,
|
||||
sourcePlatform: "chatgpt",
|
||||
sourceSurface: "memories_dialog",
|
||||
url: window.location.href,
|
||||
},
|
||||
actionSource: "chatgpt_memories_dialog",
|
||||
})
|
||||
|
||||
console.log({ response })
|
||||
debugChatGPT("memory dialog saved", {
|
||||
success: response.success,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
DOMUtils.showToast("success")
|
||||
|
|
@ -300,272 +370,242 @@ function updateChatGPTIconFeedback(
|
|||
iconElement: HTMLElement,
|
||||
resetAfter = 0,
|
||||
) {
|
||||
if (!iconElement.dataset.originalHtml) {
|
||||
iconElement.dataset.originalHtml = iconElement.innerHTML
|
||||
const memories = iconElement.dataset.memoriesData
|
||||
const fallbackReset =
|
||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
||||
|
||||
if (message === "Included Memories" || message === "Memories found") {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
||||
return
|
||||
}
|
||||
|
||||
const feedbackDiv = document.createElement("div")
|
||||
feedbackDiv.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #513EA9;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: ${message === "Included Memories" ? "pointer" : "default"};
|
||||
position: relative;
|
||||
`
|
||||
|
||||
feedbackDiv.innerHTML = `
|
||||
<span>✓</span>
|
||||
<span>${message}</span>
|
||||
`
|
||||
|
||||
if (message === "Included Memories" && iconElement.dataset.memoriesData) {
|
||||
const popup = document.createElement("div")
|
||||
popup.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
max-width: 500px;
|
||||
max-height: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 999999;
|
||||
display: none;
|
||||
border: 1px solid #333;
|
||||
`
|
||||
|
||||
const header = document.createElement("div")
|
||||
header.style.cssText = `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
opacity: 0.8;
|
||||
`
|
||||
header.innerHTML = `
|
||||
<span style="font-weight: 600; color: #fff;">Included Memories</span>
|
||||
`
|
||||
|
||||
const content = document.createElement("div")
|
||||
content.style.cssText = `
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const memoriesText = iconElement.dataset.memoriesData || ""
|
||||
console.log("Memories text:", memoriesText)
|
||||
const individualMemories = memoriesText
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
console.log("Individual memories:", individualMemories)
|
||||
|
||||
individualMemories.forEach((memory, index) => {
|
||||
const memoryItem = document.createElement("div")
|
||||
memoryItem.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const memoryText = document.createElement("div")
|
||||
memoryText.style.cssText = `
|
||||
flex: 1;
|
||||
color: #e5e5e5;
|
||||
`
|
||||
memoryText.textContent = memory.trim()
|
||||
|
||||
const removeBtn = document.createElement("button")
|
||||
removeBtn.style.cssText = `
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
removeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`
|
||||
removeBtn.dataset.memoryIndex = index.toString()
|
||||
|
||||
removeBtn.addEventListener("mouseenter", () => {
|
||||
removeBtn.style.color = "#ef4444"
|
||||
})
|
||||
removeBtn.addEventListener("mouseleave", () => {
|
||||
removeBtn.style.color = "#9ca3af"
|
||||
})
|
||||
|
||||
memoryItem.appendChild(memoryText)
|
||||
memoryItem.appendChild(removeBtn)
|
||||
content.appendChild(memoryItem)
|
||||
})
|
||||
|
||||
popup.appendChild(header)
|
||||
popup.appendChild(content)
|
||||
document.body.appendChild(popup)
|
||||
|
||||
feedbackDiv.addEventListener("mouseenter", () => {
|
||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
||||
if (textSpan) {
|
||||
textSpan.textContent = "Click to see memories"
|
||||
}
|
||||
})
|
||||
|
||||
feedbackDiv.addEventListener("mouseleave", () => {
|
||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
||||
if (textSpan) {
|
||||
textSpan.textContent = "Included Memories"
|
||||
}
|
||||
})
|
||||
|
||||
feedbackDiv.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
popup.style.display = "block"
|
||||
})
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.style.display = "none"
|
||||
}
|
||||
})
|
||||
|
||||
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
|
||||
const htmlButton = button as HTMLButtonElement
|
||||
htmlButton.addEventListener("click", () => {
|
||||
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
|
||||
const memoryItem = htmlButton.parentElement
|
||||
|
||||
if (memoryItem) {
|
||||
content.removeChild(memoryItem)
|
||||
}
|
||||
|
||||
const currentMemories = (iconElement.dataset.memoriesData || "")
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
currentMemories.splice(index, 1)
|
||||
|
||||
const updatedMemories = currentMemories.join(" ,")
|
||||
|
||||
iconElement.dataset.memoriesData = updatedMemories
|
||||
|
||||
const promptElement = document.getElementById("prompt-textarea")
|
||||
if (promptElement) {
|
||||
promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
}
|
||||
|
||||
content
|
||||
.querySelectorAll("button[data-memory-index]")
|
||||
.forEach((btn, newIndex) => {
|
||||
const htmlBtn = btn as HTMLButtonElement
|
||||
htmlBtn.dataset.memoryIndex = newIndex.toString()
|
||||
})
|
||||
|
||||
if (currentMemories.length <= 1) {
|
||||
if (promptElement?.dataset.supermemories) {
|
||||
delete promptElement.dataset.supermemories
|
||||
delete iconElement.dataset.memoriesData
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
popup.style.display = "none"
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}, 300000)
|
||||
if (message.toLowerCase().includes("searching")) {
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
showMarkerPopover(iconElement, message)
|
||||
return
|
||||
}
|
||||
|
||||
iconElement.innerHTML = ""
|
||||
iconElement.appendChild(feedbackDiv)
|
||||
|
||||
if (resetAfter > 0) {
|
||||
setTimeout(() => {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
}, resetAfter)
|
||||
}
|
||||
setMemoryMarkerStatus(
|
||||
iconElement,
|
||||
message.toLowerCase().includes("error") ? "error" : "none",
|
||||
)
|
||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
||||
}
|
||||
|
||||
function addSaveChatGPTElementBeforeComposerBtn() {
|
||||
const composerButtons = document.querySelectorAll("button.composer-btn")
|
||||
const promptInput = getChatGPTPromptInput()
|
||||
if (!promptInput) {
|
||||
debugChatGPT("prompt input not found", getChatGPTDomSnapshot())
|
||||
return
|
||||
}
|
||||
|
||||
composerButtons.forEach((button) => {
|
||||
if (button.hasAttribute("data-supermemory-icon-added-before")) {
|
||||
return
|
||||
const composer = findChatGPTComposerRoot(promptInput)
|
||||
if (!composer?.querySelector) {
|
||||
debugChatGPT("composer root not found", describeElement(promptInput))
|
||||
return
|
||||
}
|
||||
|
||||
const existingMarkers = Array.from(
|
||||
document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer"]`,
|
||||
),
|
||||
)
|
||||
if (existingMarkers.length > 1) {
|
||||
debugChatGPT("removed duplicate markers", existingMarkers.length)
|
||||
for (const marker of existingMarkers) {
|
||||
marker.remove()
|
||||
}
|
||||
} else if (existingMarkers.length === 1) {
|
||||
debugChatGPT("marker already exists")
|
||||
return
|
||||
}
|
||||
|
||||
const parent = button.parentElement
|
||||
if (!parent) return
|
||||
|
||||
const parentSiblings = parent.parentElement?.children
|
||||
if (!parentSiblings) return
|
||||
|
||||
let hasSpeechButtonSibling = false
|
||||
for (const sibling of parentSiblings) {
|
||||
if (
|
||||
sibling.getAttribute("data-testid") ===
|
||||
"composer-speech-button-container"
|
||||
) {
|
||||
hasSpeechButtonSibling = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasSpeechButtonSibling) return
|
||||
|
||||
const grandParent = parent.parentElement
|
||||
if (!grandParent) return
|
||||
|
||||
const existingIcon = grandParent.querySelector(
|
||||
`#${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer`,
|
||||
)
|
||||
if (existingIcon) {
|
||||
button.setAttribute("data-supermemory-icon-added-before", "true")
|
||||
return
|
||||
}
|
||||
|
||||
const saveChatGPTElement = createChatGPTInputBarElement(async () => {
|
||||
await getRelatedMemoriesForChatGPT(
|
||||
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED,
|
||||
)
|
||||
})
|
||||
|
||||
saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
button.setAttribute("data-supermemory-icon-added-before", "true")
|
||||
|
||||
grandParent.insertBefore(saveChatGPTElement, parent)
|
||||
|
||||
setupChatGPTAutoFetch()
|
||||
const buttons = findChatGPTComposerButtons(promptInput, composer)
|
||||
debugChatGPT("candidate ChatGPT buttons", {
|
||||
input: describeElement(promptInput),
|
||||
composer: describeElement(composer),
|
||||
buttons: buttons.map((button) => ({
|
||||
label: buttonLabel(button),
|
||||
element: describeElement(button),
|
||||
})),
|
||||
})
|
||||
|
||||
const micButton = buttons.find((button) => isChatGPTMicButton(button))
|
||||
const voiceButton = buttons.find((button) => isChatGPTVoiceButton(button))
|
||||
const sendButton = buttons.find((button) => isChatGPTSendButton(button))
|
||||
const anchorButton =
|
||||
micButton || voiceButton || sendButton || buttons[buttons.length - 1]
|
||||
const anchorSlot = findChatGPTButtonSlot(anchorButton, composer)
|
||||
const speechContainer = composer.querySelector(
|
||||
'[data-testid="composer-speech-button-container"]',
|
||||
) as HTMLElement | null
|
||||
const targetContainer =
|
||||
anchorSlot?.parentElement ||
|
||||
speechContainer?.parentElement ||
|
||||
promptInput.parentElement
|
||||
|
||||
if (!targetContainer) {
|
||||
debugChatGPT("could not find insertion target", {
|
||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
||||
input: describeElement(promptInput),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const saveChatGPTElement = createChatGPTInputBarElement(async () => {
|
||||
await getRelatedMemoriesForChatGPT(
|
||||
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED,
|
||||
)
|
||||
})
|
||||
|
||||
saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
if (anchorSlot?.parentElement === targetContainer) {
|
||||
targetContainer.insertBefore(saveChatGPTElement, anchorSlot)
|
||||
debugChatGPT("inserted marker before anchor button", {
|
||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
||||
anchorSlot: describeElement(anchorSlot),
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
} else {
|
||||
targetContainer.appendChild(saveChatGPTElement)
|
||||
debugChatGPT("inserted marker into fallback target", {
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
}
|
||||
|
||||
setupChatGPTAutoFetch()
|
||||
}
|
||||
|
||||
function getChatGPTPromptInput(): HTMLElement | null {
|
||||
return document.querySelector(
|
||||
'#prompt-textarea, [data-testid="prompt-textarea"], div[contenteditable="true"]',
|
||||
) as HTMLElement | null
|
||||
}
|
||||
|
||||
function findChatGPTComposerRoot(input: HTMLElement): HTMLElement {
|
||||
const form = input.closest("form") as HTMLElement | null
|
||||
if (form) return form
|
||||
|
||||
let current: HTMLElement | null = input
|
||||
for (let depth = 0; current && depth < 8; depth += 1) {
|
||||
if (current.querySelectorAll("button").length >= 2) {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return input.parentElement || document.body
|
||||
}
|
||||
|
||||
function findChatGPTComposerButtons(
|
||||
input: HTMLElement,
|
||||
composer: HTMLElement,
|
||||
): HTMLButtonElement[] {
|
||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
||||
if (composerButtons.length > 0) {
|
||||
return composerButtons
|
||||
}
|
||||
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
||||
|
||||
return allButtons.filter((button) => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const verticallyNear =
|
||||
Math.abs(
|
||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
||||
) < 120
|
||||
const horizontallyNear =
|
||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260
|
||||
|
||||
return verticallyNear && horizontallyNear
|
||||
})
|
||||
}
|
||||
|
||||
function buttonLabel(button: HTMLButtonElement): string {
|
||||
return [
|
||||
button.id,
|
||||
button.getAttribute("aria-label"),
|
||||
button.getAttribute("title"),
|
||||
button.getAttribute("data-testid"),
|
||||
button.getAttribute("data-test-id"),
|
||||
button.textContent,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function isChatGPTMicButton(button: HTMLButtonElement): boolean {
|
||||
return /mic|microphone|dictate/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function isChatGPTVoiceButton(button: HTMLButtonElement): boolean {
|
||||
return /voice|audio|speech/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function isChatGPTSendButton(button: HTMLButtonElement): boolean {
|
||||
const label = buttonLabel(button)
|
||||
return /composer-submit-button|send|submit/i.test(label)
|
||||
}
|
||||
|
||||
function findChatGPTButtonSlot(
|
||||
button: HTMLButtonElement | undefined,
|
||||
composer: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
if (!button) return null
|
||||
|
||||
let current: HTMLElement | null = button
|
||||
while (current?.parentElement && current.parentElement !== composer) {
|
||||
const parent: HTMLElement = current.parentElement
|
||||
const parentStyle = window.getComputedStyle(parent)
|
||||
const hasSiblingControls = parent.children.length > 1
|
||||
const isRow =
|
||||
parentStyle.display.includes("flex") &&
|
||||
parentStyle.flexDirection !== "column"
|
||||
|
||||
if (hasSiblingControls && isRow) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current || button
|
||||
}
|
||||
|
||||
function describeElement(element: Element | null): string | null {
|
||||
if (!element) return null
|
||||
|
||||
const parts = [element.tagName.toLowerCase()]
|
||||
if (element.id) parts.push(`#${element.id}`)
|
||||
if (element.className && typeof element.className === "string") {
|
||||
parts.push(
|
||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value) parts.push(`[${attr}="${value}"]`)
|
||||
}
|
||||
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
function getChatGPTDomSnapshot() {
|
||||
return {
|
||||
promptTextareas: document.querySelectorAll("#prompt-textarea").length,
|
||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
||||
.length,
|
||||
textareas: document.querySelectorAll("textarea").length,
|
||||
buttons: document.querySelectorAll("button").length,
|
||||
composerButtons: document.querySelectorAll("button.composer-btn").length,
|
||||
speechContainers: document.querySelectorAll(
|
||||
'[data-testid="composer-speech-button-container"]',
|
||||
).length,
|
||||
}
|
||||
}
|
||||
|
||||
async function setupChatGPTAutoFetch() {
|
||||
|
|
@ -586,12 +626,29 @@ async function setupChatGPTAutoFetch() {
|
|||
promptTextarea.setAttribute("data-supermemory-auto-fetch", "true")
|
||||
|
||||
const handleInput = () => {
|
||||
const content = promptTextarea.textContent?.trim() || ""
|
||||
syncAcceptedSupermemoryState(promptTextarea)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
document
|
||||
.querySelectorAll(
|
||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
||||
)
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
if (chatGPTDebounceTimeout) {
|
||||
clearTimeout(chatGPTDebounceTimeout)
|
||||
}
|
||||
|
||||
chatGPTDebounceTimeout = setTimeout(async () => {
|
||||
const content = promptTextarea.textContent?.trim() || ""
|
||||
if (hasAcceptedSupermemoryContext(promptTextarea)) {
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
return
|
||||
}
|
||||
|
||||
if (content.length > 2) {
|
||||
await getRelatedMemoriesForChatGPT(
|
||||
|
|
@ -604,6 +661,7 @@ async function setupChatGPTAutoFetch() {
|
|||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
|
|
@ -612,7 +670,7 @@ async function setupChatGPTAutoFetch() {
|
|||
})
|
||||
|
||||
if (promptTextarea.dataset.supermemories) {
|
||||
delete promptTextarea.dataset.supermemories
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
|
|
@ -631,7 +689,7 @@ function setupChatGPTPromptCapture() {
|
|||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
||||
|
||||
if (!autoCapture) {
|
||||
console.log("Auto capture prompts is disabled, skipping prompt capture")
|
||||
debugChatGPT("auto prompt capture disabled")
|
||||
return
|
||||
}
|
||||
const promptTextarea = document.getElementById("prompt-textarea")
|
||||
|
|
@ -641,26 +699,18 @@ function setupChatGPTPromptCapture() {
|
|||
promptContent = promptTextarea.textContent || ""
|
||||
}
|
||||
|
||||
const storedMemories = promptTextarea?.dataset.supermemories
|
||||
if (
|
||||
storedMemories &&
|
||||
promptTextarea &&
|
||||
!promptContent.includes("Supermemories of user")
|
||||
) {
|
||||
promptTextarea.appendChild(document.createTextNode(storedMemories))
|
||||
promptContent = promptTextarea.textContent || ""
|
||||
}
|
||||
|
||||
if (promptTextarea && promptContent.trim()) {
|
||||
console.log(`ChatGPT prompt submitted via ${source}:`, promptContent)
|
||||
|
||||
debugChatGPT("prompt submitted", {
|
||||
source,
|
||||
promptLength: promptContent.length,
|
||||
})
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
||||
data: {
|
||||
prompt: promptContent,
|
||||
platform: "chatgpt",
|
||||
source: source,
|
||||
source: window.location.href,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -682,7 +732,7 @@ function setupChatGPTPromptCapture() {
|
|||
})
|
||||
|
||||
if (promptTextarea?.dataset.supermemories) {
|
||||
delete promptTextarea.dataset.supermemories
|
||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -705,6 +755,18 @@ function setupChatGPTPromptCapture() {
|
|||
async (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
if (
|
||||
(target.id === "prompt-textarea" ||
|
||||
target.closest("#prompt-textarea")) &&
|
||||
acceptMemorySuggestion(
|
||||
event,
|
||||
"chatgpt",
|
||||
document.getElementById("prompt-textarea"),
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
target.id === "prompt-textarea" &&
|
||||
event.key === "Enter" &&
|
||||
|
|
|
|||
|
|
@ -13,18 +13,37 @@ import {
|
|||
createClaudeInputBarElement,
|
||||
DOMUtils,
|
||||
} from "../../utils/ui-components"
|
||||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
let claudeDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let claudeRouteObserver: MutationObserver | null = null
|
||||
let claudeUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let claudeObserverThrottle: NodeJS.Timeout | null = null
|
||||
const CLAUDE_DEBUG = false
|
||||
const CLAUDE_LOG_PREFIX = "[supermemory:claude]"
|
||||
|
||||
export function initializeClaude() {
|
||||
debugClaude("initializeClaude called", {
|
||||
host: window.location.hostname,
|
||||
href: window.location.href,
|
||||
})
|
||||
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.CLAUDE)) {
|
||||
debugClaude("not on Claude domain, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
if (document.body.hasAttribute("data-claude-initialized")) {
|
||||
debugClaude("already initialized")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +58,18 @@ export function initializeClaude() {
|
|||
setupClaudeRouteChangeDetection()
|
||||
|
||||
document.body.setAttribute("data-claude-initialized", "true")
|
||||
debugClaude("initialized listeners")
|
||||
}
|
||||
|
||||
function debugClaude(message: string, data?: unknown) {
|
||||
if (!CLAUDE_DEBUG) return
|
||||
|
||||
if (data === undefined) {
|
||||
console.log(CLAUDE_LOG_PREFIX, message)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(CLAUDE_LOG_PREFIX, message, data)
|
||||
}
|
||||
|
||||
function setupClaudeRouteChangeDetection() {
|
||||
|
|
@ -58,7 +89,7 @@ function setupClaudeRouteChangeDetection() {
|
|||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
console.log("Claude route changed, re-adding supermemory icon")
|
||||
debugClaude("route changed, re-adding supermemory icon", currentUrl)
|
||||
setTimeout(() => {
|
||||
addSupermemoryButtonToClaudeMemoryDialog()
|
||||
addSupermemoryIconToClaudeInput()
|
||||
|
|
@ -84,9 +115,11 @@ function setupClaudeRouteChangeDetection() {
|
|||
element.querySelector?.('[role="dialog"]') ||
|
||||
element.querySelector?.('div[contenteditable="true"]') ||
|
||||
element.querySelector?.("textarea") ||
|
||||
element.querySelector?.("button") ||
|
||||
element.matches?.('[role="dialog"]') ||
|
||||
element.matches?.('div[contenteditable="true"]') ||
|
||||
element.matches?.("textarea") ||
|
||||
element.matches?.("button") ||
|
||||
element.textContent?.includes("Manage memory")
|
||||
) {
|
||||
shouldRecheck = true
|
||||
|
|
@ -100,6 +133,7 @@ function setupClaudeRouteChangeDetection() {
|
|||
claudeObserverThrottle = setTimeout(() => {
|
||||
try {
|
||||
claudeObserverThrottle = null
|
||||
debugClaude("DOM changed near composer, rechecking UI")
|
||||
addSupermemoryButtonToClaudeMemoryDialog()
|
||||
addSupermemoryIconToClaudeInput()
|
||||
setupClaudeAutoFetch()
|
||||
|
|
@ -125,39 +159,207 @@ function setupClaudeRouteChangeDetection() {
|
|||
}
|
||||
|
||||
function addSupermemoryIconToClaudeInput() {
|
||||
const targetContainers = document.querySelectorAll(
|
||||
".relative.flex-1.flex.items-center.gap-2.shrink.min-w-0",
|
||||
const input = getClaudePromptInput()
|
||||
if (!input) {
|
||||
debugClaude("prompt input not found", getClaudeDomSnapshot())
|
||||
return
|
||||
}
|
||||
|
||||
const composer = findComposerRoot(input)
|
||||
if (!composer?.querySelector) {
|
||||
debugClaude("composer root not found", describeElement(input))
|
||||
return
|
||||
}
|
||||
|
||||
const existingMarkers = Array.from(
|
||||
document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}"]`,
|
||||
),
|
||||
)
|
||||
|
||||
targetContainers.forEach((container) => {
|
||||
if (container.hasAttribute("data-supermemory-icon-added")) {
|
||||
return
|
||||
if (existingMarkers.length > 1) {
|
||||
debugClaude("removed duplicate markers", existingMarkers.length)
|
||||
for (const marker of existingMarkers) {
|
||||
marker.remove()
|
||||
}
|
||||
} else if (existingMarkers.length === 1) {
|
||||
debugClaude("marker already exists")
|
||||
return
|
||||
}
|
||||
|
||||
const existingIcon = container.querySelector(
|
||||
`#${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}`,
|
||||
)
|
||||
if (existingIcon) {
|
||||
container.setAttribute("data-supermemory-icon-added", "true")
|
||||
return
|
||||
}
|
||||
|
||||
const supermemoryIcon = createClaudeInputBarElement(async () => {
|
||||
await getRelatedMemoriesForClaude(
|
||||
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED,
|
||||
)
|
||||
})
|
||||
|
||||
supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
container.setAttribute("data-supermemory-icon-added", "true")
|
||||
|
||||
container.insertBefore(supermemoryIcon, container.firstChild)
|
||||
const buttons = findClaudeComposerButtons(input, composer)
|
||||
debugClaude("candidate Claude buttons", {
|
||||
input: describeElement(input),
|
||||
composer: describeElement(composer),
|
||||
buttons: buttons.map((button) => ({
|
||||
label: buttonLabel(button),
|
||||
element: describeElement(button),
|
||||
})),
|
||||
})
|
||||
|
||||
const micButton = buttons.find((button) => isClaudeMicButton(button))
|
||||
const voiceButton = buttons.find((button) => isClaudeVoiceButton(button))
|
||||
const sendButton = buttons.find((button) => isClaudeSendButton(button))
|
||||
const anchorButton =
|
||||
micButton || voiceButton || sendButton || buttons[buttons.length - 1]
|
||||
const anchorSlot = findClaudeButtonSlot(anchorButton, composer)
|
||||
const targetContainer = anchorSlot?.parentElement || input.parentElement
|
||||
|
||||
if (!targetContainer) {
|
||||
debugClaude("could not find insertion target", {
|
||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
||||
input: describeElement(input),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const supermemoryIcon = createClaudeInputBarElement(async () => {
|
||||
await getRelatedMemoriesForClaude(
|
||||
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED,
|
||||
)
|
||||
})
|
||||
|
||||
supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
if (anchorSlot?.parentElement === targetContainer) {
|
||||
targetContainer.insertBefore(supermemoryIcon, anchorSlot)
|
||||
debugClaude("inserted marker before anchor button", {
|
||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
||||
anchorSlot: describeElement(anchorSlot),
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
targetContainer.appendChild(supermemoryIcon)
|
||||
debugClaude("inserted marker into fallback target", {
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
}
|
||||
|
||||
function getClaudePromptInput(): HTMLElement | null {
|
||||
return document.querySelector(
|
||||
'.ProseMirror[contenteditable="true"], div[contenteditable="true"], textarea',
|
||||
) as HTMLElement | null
|
||||
}
|
||||
|
||||
function findComposerRoot(input: HTMLElement): HTMLElement {
|
||||
return (
|
||||
(input.closest("form") as HTMLElement | null) ||
|
||||
(input.closest('[data-testid*="composer"]') as HTMLElement | null) ||
|
||||
(input.closest('[class*="composer"]') as HTMLElement | null) ||
|
||||
(input.closest(".relative") as HTMLElement | null) ||
|
||||
input.parentElement ||
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
function buttonLabel(button: HTMLButtonElement): string {
|
||||
return [
|
||||
button.getAttribute("aria-label"),
|
||||
button.getAttribute("title"),
|
||||
button.getAttribute("data-testid"),
|
||||
button.getAttribute("data-test-id"),
|
||||
button.textContent,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function findClaudeComposerButtons(
|
||||
input: HTMLElement,
|
||||
composer: HTMLElement,
|
||||
): HTMLButtonElement[] {
|
||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
||||
if (composerButtons.length > 0) {
|
||||
return composerButtons
|
||||
}
|
||||
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
||||
|
||||
return allButtons.filter((button) => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const verticallyNear =
|
||||
Math.abs(
|
||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
||||
) < 120
|
||||
const horizontallyNear =
|
||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260
|
||||
|
||||
return verticallyNear && horizontallyNear
|
||||
})
|
||||
}
|
||||
|
||||
function isClaudeMicButton(button: HTMLButtonElement): boolean {
|
||||
return /mic|microphone|dictate/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function isClaudeVoiceButton(button: HTMLButtonElement): boolean {
|
||||
return /voice|audio|speech/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function isClaudeSendButton(button: HTMLButtonElement): boolean {
|
||||
return /send|submit/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function findClaudeButtonSlot(
|
||||
button: HTMLButtonElement | undefined,
|
||||
composer: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
if (!button) return null
|
||||
|
||||
let current: HTMLElement | null = button
|
||||
while (current?.parentElement && current.parentElement !== composer) {
|
||||
const parent: HTMLElement = current.parentElement
|
||||
const parentStyle = window.getComputedStyle(parent)
|
||||
const hasSiblingControls = parent.children.length > 1
|
||||
const isRow =
|
||||
parentStyle.display.includes("flex") &&
|
||||
parentStyle.flexDirection !== "column"
|
||||
|
||||
if (hasSiblingControls && isRow) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current || button
|
||||
}
|
||||
|
||||
function describeElement(element: Element | null): string | null {
|
||||
if (!element) return null
|
||||
|
||||
const parts = [element.tagName.toLowerCase()]
|
||||
if (element.id) parts.push(`#${element.id}`)
|
||||
if (element.className && typeof element.className === "string") {
|
||||
parts.push(
|
||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value) parts.push(`[${attr}="${value}"]`)
|
||||
}
|
||||
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
function getClaudeDomSnapshot() {
|
||||
return {
|
||||
proseMirrors: document.querySelectorAll(".ProseMirror").length,
|
||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
||||
.length,
|
||||
textareas: document.querySelectorAll("textarea").length,
|
||||
buttons: document.querySelectorAll("button").length,
|
||||
}
|
||||
}
|
||||
|
||||
async function getRelatedMemoriesForClaude(actionSource: string) {
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
let userQuery = ""
|
||||
|
||||
const supermemoryContainer = document.querySelector(
|
||||
|
|
@ -194,10 +396,12 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("Claude query extracted:", userQuery)
|
||||
debugClaude("query extracted", {
|
||||
queryLength: userQuery.length,
|
||||
})
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
console.log("No query text found for Claude")
|
||||
debugClaude("memory search skipped because query is empty")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +414,15 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
return
|
||||
}
|
||||
|
||||
updateClaudeIconFeedback("Searching memories...", iconElement)
|
||||
if (isAutoSearch) {
|
||||
const input = getClaudePromptInput()
|
||||
if (input) {
|
||||
showLoadingSuggestion("claude", input)
|
||||
}
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
} else {
|
||||
updateClaudeIconFeedback("Searching memories...", iconElement)
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
|
|
@ -228,7 +440,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
console.log("Claude memories response:", response)
|
||||
debugClaude("memory search response", {
|
||||
success: response?.success,
|
||||
})
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
const textareaElement = document.querySelector(
|
||||
|
|
@ -236,24 +450,39 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
) as HTMLElement
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
console.log(
|
||||
"Text element dataset:",
|
||||
textareaElement.dataset.supermemories,
|
||||
const memoryText = showMemorySuggestion(
|
||||
"claude",
|
||||
textareaElement,
|
||||
response.data,
|
||||
)
|
||||
debugClaude("memory suggestion rendered", {
|
||||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = response.data
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
|
||||
updateClaudeIconFeedback("Included Memories", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
} else {
|
||||
updateClaudeIconFeedback("Included Memories", iconElement)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Claude input area not found after successful memory fetch",
|
||||
)
|
||||
updateClaudeIconFeedback("Memories found", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
} else {
|
||||
updateClaudeIconFeedback("Memories found", iconElement)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("No memories found or API response invalid for Claude")
|
||||
updateClaudeIconFeedback("No memories found", iconElement)
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
updateClaudeIconFeedback("No memories found", iconElement)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting related memories for Claude:", error)
|
||||
|
|
@ -262,7 +491,13 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
'[id*="sm-claude-input-bar-element"]',
|
||||
) as HTMLElement
|
||||
if (icon) {
|
||||
updateClaudeIconFeedback("Error fetching memories", icon)
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
) {
|
||||
setMemoryMarkerStatus(icon, "error")
|
||||
} else {
|
||||
updateClaudeIconFeedback("Error fetching memories", icon)
|
||||
}
|
||||
}
|
||||
} catch (feedbackError) {
|
||||
console.error("Failed to update Claude error feedback:", feedbackError)
|
||||
|
|
@ -441,7 +676,9 @@ async function saveClaudeMemoriesToSupermemory(memoryDialog: HTMLElement) {
|
|||
actionSource: "claude_memories_dialog",
|
||||
})
|
||||
|
||||
console.log({ response })
|
||||
debugClaude("memory dialog saved", {
|
||||
success: response.success,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
DOMUtils.showToast("success")
|
||||
|
|
@ -459,220 +696,27 @@ function updateClaudeIconFeedback(
|
|||
iconElement: HTMLElement,
|
||||
resetAfter = 0,
|
||||
) {
|
||||
if (!iconElement.dataset.originalHtml) {
|
||||
iconElement.dataset.originalHtml = iconElement.innerHTML
|
||||
const memories = iconElement.dataset.memoriesData
|
||||
const fallbackReset =
|
||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
||||
|
||||
if (message === "Included Memories" || message === "Memories found") {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
||||
return
|
||||
}
|
||||
|
||||
const feedbackDiv = document.createElement("div")
|
||||
feedbackDiv.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #513EA9;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: ${message === "Included Memories" ? "pointer" : "default"};
|
||||
position: relative;
|
||||
`
|
||||
|
||||
feedbackDiv.innerHTML = `
|
||||
<span>✓</span>
|
||||
<span>${message}</span>
|
||||
`
|
||||
|
||||
if (message === "Included Memories" && iconElement.dataset.memoriesData) {
|
||||
const popup = document.createElement("div")
|
||||
popup.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
max-width: 500px;
|
||||
max-height: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 999999;
|
||||
display: none;
|
||||
border: 1px solid #333;
|
||||
`
|
||||
|
||||
const header = document.createElement("div")
|
||||
header.style.cssText = `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
opacity: 0.8;
|
||||
`
|
||||
header.innerHTML = `
|
||||
<span style="font-weight: 600; color: #fff;">Included Memories</span>
|
||||
`
|
||||
|
||||
const content = document.createElement("div")
|
||||
content.style.cssText = `
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const memoriesText = iconElement.dataset.memoriesData || ""
|
||||
console.log("Memories text:", memoriesText)
|
||||
const individualMemories = memoriesText
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
console.log("Individual memories:", individualMemories)
|
||||
|
||||
individualMemories.forEach((memory, index) => {
|
||||
const memoryItem = document.createElement("div")
|
||||
memoryItem.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
const memoryText = document.createElement("div")
|
||||
memoryText.style.cssText = `
|
||||
flex: 1;
|
||||
color: #e5e5e5;
|
||||
`
|
||||
memoryText.textContent = memory.trim()
|
||||
|
||||
const removeBtn = document.createElement("button")
|
||||
removeBtn.style.cssText = `
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
removeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`
|
||||
removeBtn.dataset.memoryIndex = index.toString()
|
||||
|
||||
removeBtn.addEventListener("mouseenter", () => {
|
||||
removeBtn.style.color = "#ef4444"
|
||||
})
|
||||
removeBtn.addEventListener("mouseleave", () => {
|
||||
removeBtn.style.color = "#9ca3af"
|
||||
})
|
||||
|
||||
memoryItem.appendChild(memoryText)
|
||||
memoryItem.appendChild(removeBtn)
|
||||
content.appendChild(memoryItem)
|
||||
})
|
||||
|
||||
popup.appendChild(header)
|
||||
popup.appendChild(content)
|
||||
document.body.appendChild(popup)
|
||||
|
||||
feedbackDiv.addEventListener("mouseenter", () => {
|
||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
||||
if (textSpan) {
|
||||
textSpan.textContent = "Click to see memories"
|
||||
}
|
||||
})
|
||||
|
||||
feedbackDiv.addEventListener("mouseleave", () => {
|
||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
||||
if (textSpan) {
|
||||
textSpan.textContent = "Included Memories"
|
||||
}
|
||||
})
|
||||
|
||||
feedbackDiv.addEventListener("click", (e) => {
|
||||
e.stopPropagation()
|
||||
popup.style.display = "block"
|
||||
})
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.style.display = "none"
|
||||
}
|
||||
})
|
||||
|
||||
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
|
||||
const htmlButton = button as HTMLButtonElement
|
||||
htmlButton.addEventListener("click", () => {
|
||||
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
|
||||
const memoryItem = htmlButton.parentElement
|
||||
|
||||
if (memoryItem) {
|
||||
content.removeChild(memoryItem)
|
||||
}
|
||||
|
||||
const currentMemories = (iconElement.dataset.memoriesData || "")
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
currentMemories.splice(index, 1)
|
||||
|
||||
const updatedMemories = currentMemories.join(" ,")
|
||||
|
||||
iconElement.dataset.memoriesData = updatedMemories
|
||||
|
||||
const textareaElement = document.querySelector(
|
||||
'div[contenteditable="true"]',
|
||||
) as HTMLElement
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
}
|
||||
|
||||
content
|
||||
.querySelectorAll("button[data-memory-index]")
|
||||
.forEach((btn, newIndex) => {
|
||||
const htmlBtn = btn as HTMLButtonElement
|
||||
htmlBtn.dataset.memoryIndex = newIndex.toString()
|
||||
})
|
||||
|
||||
if (currentMemories.length <= 1) {
|
||||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
delete iconElement.dataset.memoriesData
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
popup.style.display = "none"
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(popup)) {
|
||||
document.body.removeChild(popup)
|
||||
}
|
||||
}, 300000)
|
||||
if (message.toLowerCase().includes("searching")) {
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
showMarkerPopover(iconElement, message)
|
||||
return
|
||||
}
|
||||
|
||||
iconElement.innerHTML = ""
|
||||
iconElement.appendChild(feedbackDiv)
|
||||
|
||||
if (resetAfter > 0) {
|
||||
setTimeout(() => {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
}, resetAfter)
|
||||
}
|
||||
setMemoryMarkerStatus(
|
||||
iconElement,
|
||||
message.toLowerCase().includes("error") ? "error" : "none",
|
||||
)
|
||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
||||
}
|
||||
|
||||
function setupClaudePromptCapture() {
|
||||
|
|
@ -684,7 +728,7 @@ function setupClaudePromptCapture() {
|
|||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
||||
|
||||
if (!autoCapture) {
|
||||
console.log("Auto capture prompts is disabled, skipping prompt capture")
|
||||
debugClaude("auto prompt capture disabled")
|
||||
return
|
||||
}
|
||||
let promptContent = ""
|
||||
|
|
@ -704,19 +748,11 @@ function setupClaudePromptCapture() {
|
|||
}
|
||||
}
|
||||
|
||||
const storedMemories = contentEditableDiv?.dataset.supermemories
|
||||
if (
|
||||
storedMemories &&
|
||||
contentEditableDiv &&
|
||||
!promptContent.includes("Supermemories of user")
|
||||
) {
|
||||
contentEditableDiv.appendChild(document.createTextNode(storedMemories))
|
||||
promptContent =
|
||||
contentEditableDiv.textContent || contentEditableDiv.innerText || ""
|
||||
}
|
||||
|
||||
if (promptContent.trim()) {
|
||||
console.log(`Claude prompt submitted via ${source}:`, promptContent)
|
||||
debugClaude("prompt submitted", {
|
||||
source,
|
||||
promptLength: promptContent.length,
|
||||
})
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
|
|
@ -724,7 +760,7 @@ function setupClaudePromptCapture() {
|
|||
data: {
|
||||
prompt: promptContent,
|
||||
platform: "claude",
|
||||
source: source,
|
||||
source: window.location.href,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -746,7 +782,7 @@ function setupClaudePromptCapture() {
|
|||
})
|
||||
|
||||
if (contentEditableDiv?.dataset.supermemories) {
|
||||
delete contentEditableDiv.dataset.supermemories
|
||||
clearMemorySuggestion("claude", contentEditableDiv)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -754,14 +790,16 @@ function setupClaudePromptCapture() {
|
|||
"click",
|
||||
async (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const sendButton =
|
||||
target.closest(
|
||||
"button.inline-flex.items-center.justify-center.relative.shrink-0.can-focus.select-none",
|
||||
) ||
|
||||
target.closest('button[class*="bg-accent-main-000"]') ||
|
||||
target.closest('button[class*="rounded-lg"]')
|
||||
if (target.closest('[data-supermemory-connected-indicator="true"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sendButton) {
|
||||
const sendButton = target.closest("button")
|
||||
|
||||
if (
|
||||
sendButton &&
|
||||
buttonLabel(sendButton as HTMLButtonElement).match(/send|submit/i)
|
||||
) {
|
||||
await captureClaudePromptContent("button click")
|
||||
}
|
||||
},
|
||||
|
|
@ -773,10 +811,18 @@ function setupClaudePromptCapture() {
|
|||
async (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
const activeInput =
|
||||
(target.closest('div[contenteditable="true"]') as HTMLElement | null) ||
|
||||
(target.matches("textarea") ? (target as HTMLTextAreaElement) : null)
|
||||
if (acceptMemorySuggestion(event, "claude", activeInput)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(target.matches('div[contenteditable="true"]') ||
|
||||
target.matches(".ProseMirror") ||
|
||||
target.matches("textarea") ||
|
||||
target.closest('div[contenteditable="true"]') ||
|
||||
target.closest(".ProseMirror")) &&
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey
|
||||
|
|
@ -808,12 +854,27 @@ async function setupClaudeAutoFetch() {
|
|||
textareaElement.setAttribute("data-supermemory-auto-fetch", "true")
|
||||
|
||||
const handleInput = () => {
|
||||
const content = textareaElement.textContent?.trim() || ""
|
||||
syncAcceptedSupermemoryState(textareaElement)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
document
|
||||
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
if (claudeDebounceTimeout) {
|
||||
clearTimeout(claudeDebounceTimeout)
|
||||
}
|
||||
|
||||
claudeDebounceTimeout = setTimeout(async () => {
|
||||
const content = textareaElement.textContent?.trim() || ""
|
||||
if (hasAcceptedSupermemoryContext(textareaElement)) {
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
return
|
||||
}
|
||||
|
||||
if (content.length > 2) {
|
||||
await getRelatedMemoriesForClaude(
|
||||
|
|
@ -826,6 +887,7 @@ async function setupClaudeAutoFetch() {
|
|||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
setMemoryMarkerStatus(iconElement, "neutral")
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
|
|
@ -834,7 +896,7 @@ async function setupClaudeAutoFetch() {
|
|||
})
|
||||
|
||||
if (textareaElement.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
clearMemorySuggestion("claude", textareaElement)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
|
|
|
|||
661
apps/browser-extension/entrypoints/content/gemini.ts
Normal file
661
apps/browser-extension/entrypoints/content/gemini.ts
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
import {
|
||||
DOMAINS,
|
||||
ELEMENT_IDS,
|
||||
MESSAGE_TYPES,
|
||||
POSTHOG_EVENT_KEY,
|
||||
UI_CONFIG,
|
||||
} from "../../utils/constants"
|
||||
import {
|
||||
autoCapturePromptsEnabled,
|
||||
autoSearchEnabled,
|
||||
} from "../../utils/storage"
|
||||
import {
|
||||
createGeminiInputBarElement,
|
||||
DOMUtils,
|
||||
} from "../../utils/ui-components"
|
||||
import {
|
||||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
showMemorySuggestion,
|
||||
syncAcceptedSupermemoryState,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
let geminiDebounceTimeout: NodeJS.Timeout | null = null
|
||||
let geminiRouteObserver: MutationObserver | null = null
|
||||
let geminiUrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let geminiObserverThrottle: NodeJS.Timeout | null = null
|
||||
const GEMINI_DEBUG = false
|
||||
const GEMINI_LOG_PREFIX = "[supermemory:gemini]"
|
||||
|
||||
type GeminiInput = HTMLElement | HTMLTextAreaElement
|
||||
|
||||
export function initializeGemini() {
|
||||
debugGemini("initializeGemini called", {
|
||||
host: window.location.hostname,
|
||||
href: window.location.href,
|
||||
})
|
||||
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.GEMINI)) {
|
||||
debugGemini("not on Gemini domain, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
if (document.body.hasAttribute("data-gemini-initialized")) {
|
||||
debugGemini("already initialized")
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
addSupermemoryIconToGeminiInput()
|
||||
setupGeminiAutoFetch()
|
||||
}, 2000)
|
||||
|
||||
setupGeminiPromptCapture()
|
||||
setupGeminiRouteChangeDetection()
|
||||
|
||||
document.body.setAttribute("data-gemini-initialized", "true")
|
||||
debugGemini("initialized listeners")
|
||||
}
|
||||
|
||||
function debugGemini(message: string, data?: unknown) {
|
||||
if (!GEMINI_DEBUG) return
|
||||
|
||||
if (data === undefined) {
|
||||
console.log(GEMINI_LOG_PREFIX, message)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(GEMINI_LOG_PREFIX, message, data)
|
||||
}
|
||||
|
||||
function setupGeminiRouteChangeDetection() {
|
||||
if (geminiRouteObserver) {
|
||||
geminiRouteObserver.disconnect()
|
||||
}
|
||||
if (geminiUrlCheckInterval) {
|
||||
clearInterval(geminiUrlCheckInterval)
|
||||
}
|
||||
if (geminiObserverThrottle) {
|
||||
clearTimeout(geminiObserverThrottle)
|
||||
geminiObserverThrottle = null
|
||||
}
|
||||
|
||||
let currentUrl = window.location.href
|
||||
|
||||
const recheckGeminiUI = () => {
|
||||
addSupermemoryIconToGeminiInput()
|
||||
setupGeminiAutoFetch()
|
||||
}
|
||||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
debugGemini("route changed, rechecking UI", currentUrl)
|
||||
setTimeout(recheckGeminiUI, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
||||
|
||||
geminiRouteObserver = new MutationObserver((mutations) => {
|
||||
if (geminiObserverThrottle) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRecheck = mutations.some((mutation) =>
|
||||
Array.from(mutation.addedNodes).some((node) => {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
const element = node as Element
|
||||
return (
|
||||
element.matches?.("rich-textarea, textarea, button") ||
|
||||
element.matches?.('[contenteditable="true"]') ||
|
||||
!!element.querySelector?.(
|
||||
'rich-textarea, textarea, button, [contenteditable="true"]',
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
if (shouldRecheck) {
|
||||
geminiObserverThrottle = setTimeout(() => {
|
||||
geminiObserverThrottle = null
|
||||
debugGemini("DOM changed near Gemini composer, rechecking UI")
|
||||
recheckGeminiUI()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
geminiRouteObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to set up Gemini route observer:", error)
|
||||
if (geminiUrlCheckInterval) {
|
||||
clearInterval(geminiUrlCheckInterval)
|
||||
}
|
||||
geminiUrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function addSupermemoryIconToGeminiInput() {
|
||||
const input = getGeminiPromptInput()
|
||||
if (!input) {
|
||||
debugGemini("prompt input not found", getGeminiDomSnapshot())
|
||||
return
|
||||
}
|
||||
|
||||
const composer = findGeminiComposerRoot(input)
|
||||
if (!composer?.querySelector) {
|
||||
debugGemini("composer root not found", describeElement(input))
|
||||
return
|
||||
}
|
||||
|
||||
const existingMarkers = Array.from(
|
||||
document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
),
|
||||
)
|
||||
if (existingMarkers.length > 1) {
|
||||
debugGemini("removed duplicate markers", existingMarkers.length)
|
||||
for (const marker of existingMarkers) {
|
||||
marker.remove()
|
||||
}
|
||||
} else if (existingMarkers.length === 1) {
|
||||
debugGemini("marker already exists")
|
||||
return
|
||||
}
|
||||
|
||||
const buttons = findGeminiComposerButtons(input, composer)
|
||||
debugGemini("candidate Gemini buttons", {
|
||||
input: describeElement(input),
|
||||
composer: describeElement(composer),
|
||||
buttons: buttons.map((button) => ({
|
||||
label: buttonLabel(button),
|
||||
element: describeElement(button),
|
||||
})),
|
||||
})
|
||||
|
||||
const micButton = buttons.find((button) => isGeminiMicButton(button))
|
||||
const sendButton = buttons.find((button) => isGeminiSendButton(button))
|
||||
const anchorButton = micButton || sendButton || buttons[buttons.length - 1]
|
||||
const anchorSlot = findGeminiButtonSlot(anchorButton, composer)
|
||||
const targetContainer =
|
||||
anchorSlot?.parentElement ||
|
||||
(input.closest("rich-textarea") as HTMLElement | null)?.parentElement ||
|
||||
input.parentElement
|
||||
|
||||
if (!targetContainer) {
|
||||
debugGemini("could not find insertion target", {
|
||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
||||
input: describeElement(input),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const supermemoryIcon = createGeminiInputBarElement(async () => {
|
||||
await getRelatedMemoriesForGemini(
|
||||
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_SEARCHED,
|
||||
)
|
||||
})
|
||||
|
||||
supermemoryIcon.id = `${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
if (anchorSlot?.parentElement === targetContainer) {
|
||||
targetContainer.insertBefore(supermemoryIcon, anchorSlot)
|
||||
debugGemini("inserted marker before anchor button", {
|
||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
||||
anchorSlot: describeElement(anchorSlot),
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
targetContainer.appendChild(supermemoryIcon)
|
||||
debugGemini("inserted marker into fallback target", {
|
||||
target: describeElement(targetContainer),
|
||||
})
|
||||
}
|
||||
|
||||
function getGeminiPromptInput(): GeminiInput | null {
|
||||
return document.querySelector(
|
||||
'rich-textarea .ql-editor[contenteditable="true"], rich-textarea [contenteditable="true"], .ql-editor[contenteditable="true"], div[contenteditable="true"], textarea',
|
||||
) as GeminiInput | null
|
||||
}
|
||||
|
||||
function findGeminiComposerRoot(input: GeminiInput): HTMLElement {
|
||||
const form = input.closest("form") as HTMLElement | null
|
||||
if (form) return form
|
||||
|
||||
let current: HTMLElement | null = input
|
||||
for (let depth = 0; current && depth < 8; depth += 1) {
|
||||
if (current.querySelectorAll("button").length >= 2) {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return input.parentElement || document.body
|
||||
}
|
||||
|
||||
function findGeminiComposerButtons(
|
||||
input: GeminiInput,
|
||||
composer: HTMLElement,
|
||||
): HTMLButtonElement[] {
|
||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
||||
if (composerButtons.length > 0) {
|
||||
return composerButtons
|
||||
}
|
||||
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
||||
|
||||
return allButtons.filter((button) => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const verticallyNear =
|
||||
Math.abs(
|
||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
||||
) < 120
|
||||
const horizontallyNear =
|
||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 240
|
||||
|
||||
return verticallyNear && horizontallyNear
|
||||
})
|
||||
}
|
||||
|
||||
function buttonLabel(button: HTMLButtonElement): string {
|
||||
return [
|
||||
button.getAttribute("aria-label"),
|
||||
button.getAttribute("title"),
|
||||
button.getAttribute("data-testid"),
|
||||
button.getAttribute("data-test-id"),
|
||||
button.getAttribute("jsname"),
|
||||
button.textContent,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function isGeminiMicButton(button: HTMLButtonElement): boolean {
|
||||
return /mic|microphone|voice|dictate|audio/i.test(buttonLabel(button))
|
||||
}
|
||||
|
||||
function isGeminiSendButton(button: HTMLButtonElement): boolean {
|
||||
const label = buttonLabel(button)
|
||||
if (/send|submit/i.test(label)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !!button.querySelector(
|
||||
'mat-icon[fonticon="send"], mat-icon[data-mat-icon-name="send"], [data-icon-name="send"]',
|
||||
)
|
||||
}
|
||||
|
||||
function findGeminiButtonSlot(
|
||||
button: HTMLButtonElement | undefined,
|
||||
composer: HTMLElement,
|
||||
): HTMLElement | null {
|
||||
if (!button) return null
|
||||
|
||||
let current: HTMLElement | null = button
|
||||
while (current?.parentElement && current.parentElement !== composer) {
|
||||
const parent: HTMLElement = current.parentElement
|
||||
const parentStyle = window.getComputedStyle(parent)
|
||||
const hasSiblingControls = parent.children.length > 1
|
||||
const isRow =
|
||||
parentStyle.display.includes("flex") &&
|
||||
parentStyle.flexDirection !== "column"
|
||||
|
||||
if (hasSiblingControls && isRow) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current || button
|
||||
}
|
||||
|
||||
function describeElement(element: Element | null): string | null {
|
||||
if (!element) return null
|
||||
|
||||
const parts = [element.tagName.toLowerCase()]
|
||||
if (element.id) parts.push(`#${element.id}`)
|
||||
if (element.className && typeof element.className === "string") {
|
||||
parts.push(
|
||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value) parts.push(`[${attr}="${value}"]`)
|
||||
}
|
||||
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
function getGeminiDomSnapshot() {
|
||||
return {
|
||||
richTextareas: document.querySelectorAll("rich-textarea").length,
|
||||
qlEditors: document.querySelectorAll(".ql-editor").length,
|
||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
||||
.length,
|
||||
textareas: document.querySelectorAll("textarea").length,
|
||||
buttons: document.querySelectorAll("button").length,
|
||||
}
|
||||
}
|
||||
|
||||
function getInputText(input: GeminiInput | null): string {
|
||||
if (!input) return ""
|
||||
if (input instanceof HTMLTextAreaElement) {
|
||||
return input.value || ""
|
||||
}
|
||||
|
||||
return input.innerText || input.textContent || ""
|
||||
}
|
||||
|
||||
async function getRelatedMemoriesForGemini(actionSource: string) {
|
||||
try {
|
||||
const isAutoSearch =
|
||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
const input = getGeminiPromptInput()
|
||||
const userQuery = getInputText(input).trim()
|
||||
debugGemini("manual/auto memory search requested", {
|
||||
actionSource,
|
||||
hasInput: !!input,
|
||||
queryLength: userQuery.length,
|
||||
})
|
||||
|
||||
if (!userQuery) {
|
||||
debugGemini("memory search skipped because query is empty")
|
||||
return
|
||||
}
|
||||
|
||||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
) as HTMLElement | null
|
||||
|
||||
if (!iconElement) {
|
||||
console.warn("Gemini icon element not found, cannot update feedback")
|
||||
return
|
||||
}
|
||||
|
||||
if (input && isAutoSearch) {
|
||||
showLoadingSuggestion("gemini", input)
|
||||
}
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
if (!isAutoSearch) {
|
||||
updateGeminiIconFeedback("Searching memories...", iconElement)
|
||||
}
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Memory search timeout")),
|
||||
UI_CONFIG.API_REQUEST_TIMEOUT,
|
||||
),
|
||||
)
|
||||
|
||||
const response = (await Promise.race([
|
||||
browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
|
||||
data: userQuery,
|
||||
actionSource,
|
||||
}),
|
||||
timeoutPromise,
|
||||
])) as { success?: boolean; data?: string }
|
||||
|
||||
debugGemini("memory search response", response)
|
||||
|
||||
if (response?.success && response?.data && input) {
|
||||
const memoryText = showMemorySuggestion("gemini", input, response.data)
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.supermemories = memoryText
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
} else {
|
||||
updateGeminiIconFeedback("Included Memories", iconElement)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "none")
|
||||
} else {
|
||||
updateGeminiIconFeedback("No memories found", iconElement, 1800)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting related memories for Gemini:", error)
|
||||
const iconElement = document.querySelector(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
) as HTMLElement | null
|
||||
if (iconElement) {
|
||||
if (
|
||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
||||
) {
|
||||
setMemoryMarkerStatus(iconElement, "error")
|
||||
} else {
|
||||
updateGeminiIconFeedback("Error fetching memories", iconElement, 1800)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGeminiIconFeedback(
|
||||
message: string,
|
||||
iconElement: HTMLElement,
|
||||
resetAfter = 0,
|
||||
) {
|
||||
const memories = iconElement.dataset.memoriesData
|
||||
const fallbackReset =
|
||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
||||
|
||||
if (message === "Included Memories" || message === "Memories found") {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.toLowerCase().includes("searching")) {
|
||||
setMemoryMarkerStatus(iconElement, "searching")
|
||||
showMarkerPopover(iconElement, message)
|
||||
return
|
||||
}
|
||||
|
||||
setMemoryMarkerStatus(
|
||||
iconElement,
|
||||
message.toLowerCase().includes("error") ? "error" : "none",
|
||||
)
|
||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
||||
}
|
||||
|
||||
function setupGeminiPromptCapture() {
|
||||
if (document.body.hasAttribute("data-gemini-prompt-capture-setup")) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.setAttribute("data-gemini-prompt-capture-setup", "true")
|
||||
|
||||
const captureGeminiPromptContent = async (source: string) => {
|
||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
||||
debugGemini("capture requested", { source, autoCapture })
|
||||
|
||||
if (!autoCapture) {
|
||||
debugGemini("auto prompt capture disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const input = getGeminiPromptInput()
|
||||
const promptContent = getInputText(input)
|
||||
debugGemini("capture input state", {
|
||||
hasInput: !!input,
|
||||
promptLength: promptContent.length,
|
||||
hasStoredMemories: !!input?.dataset.supermemories,
|
||||
})
|
||||
|
||||
if (promptContent.trim()) {
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
||||
data: {
|
||||
prompt: promptContent,
|
||||
platform: "gemini",
|
||||
source: window.location.href,
|
||||
},
|
||||
})
|
||||
debugGemini("capture response", response)
|
||||
} catch (error) {
|
||||
console.error("Error sending Gemini prompt to background:", error)
|
||||
}
|
||||
} else {
|
||||
debugGemini("capture skipped because prompt is empty")
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
||||
delete iconElement.dataset.supermemoryStatus
|
||||
delete iconElement.dataset.memoriesData
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
})
|
||||
|
||||
if (input?.dataset.supermemories) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
async (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-supermemory-connected-indicator="true"]')) {
|
||||
return
|
||||
}
|
||||
|
||||
const sendButton = target.closest("button")
|
||||
if (sendButton && isGeminiSendButton(sendButton as HTMLButtonElement)) {
|
||||
debugGemini("send button click detected", {
|
||||
label: buttonLabel(sendButton as HTMLButtonElement),
|
||||
element: describeElement(sendButton),
|
||||
})
|
||||
await captureGeminiPromptContent("button click")
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
async (event) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
const activeInput =
|
||||
(target.closest('[contenteditable="true"]') as GeminiInput | null) ||
|
||||
(target.matches("textarea") ? (target as HTMLTextAreaElement) : null)
|
||||
if (acceptMemorySuggestion(event, "gemini", activeInput)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(target.matches("textarea") ||
|
||||
target.matches('[contenteditable="true"]') ||
|
||||
target.closest('[contenteditable="true"]')) &&
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
debugGemini("Enter submit detected", {
|
||||
target: describeElement(target),
|
||||
})
|
||||
await captureGeminiPromptContent("Enter key")
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
async function setupGeminiAutoFetch() {
|
||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
||||
debugGemini("setup auto fetch", { autoSearch })
|
||||
if (!autoSearch) {
|
||||
return
|
||||
}
|
||||
|
||||
const input = getGeminiPromptInput()
|
||||
if (!input || input.hasAttribute("data-supermemory-auto-fetch")) {
|
||||
debugGemini("auto fetch skipped", {
|
||||
hasInput: !!input,
|
||||
alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input.setAttribute("data-supermemory-auto-fetch", "true")
|
||||
debugGemini("auto fetch attached", describeElement(input))
|
||||
|
||||
const handleInput = () => {
|
||||
const content = getInputText(input).trim()
|
||||
syncAcceptedSupermemoryState(input)
|
||||
|
||||
if (content.length === 0) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
document
|
||||
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
|
||||
.forEach((icon) => {
|
||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
||||
})
|
||||
}
|
||||
|
||||
if (geminiDebounceTimeout) {
|
||||
clearTimeout(geminiDebounceTimeout)
|
||||
}
|
||||
|
||||
geminiDebounceTimeout = setTimeout(async () => {
|
||||
if (hasAcceptedSupermemoryContext(input)) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
return
|
||||
}
|
||||
|
||||
if (content.length > 2) {
|
||||
await getRelatedMemoriesForGemini(
|
||||
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED,
|
||||
)
|
||||
} else if (content.length === 0) {
|
||||
const icons = document.querySelectorAll(
|
||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
||||
)
|
||||
|
||||
icons.forEach((icon) => {
|
||||
const iconElement = icon as HTMLElement
|
||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
||||
delete iconElement.dataset.supermemoryStatus
|
||||
delete iconElement.dataset.memoriesData
|
||||
if (iconElement.dataset.originalHtml) {
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
||||
delete iconElement.dataset.originalHtml
|
||||
}
|
||||
})
|
||||
|
||||
if (input.dataset.supermemories) {
|
||||
clearMemorySuggestion("gemini", input)
|
||||
}
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
||||
input.addEventListener("input", handleInput)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { DOMUtils } from "../../utils/ui-components"
|
|||
import { initializeChatGPT } from "./chatgpt"
|
||||
import { initializeClaude } from "./claude"
|
||||
import { initializeGrok } from "./grok"
|
||||
import { initializeGemini } from "./gemini"
|
||||
import {
|
||||
saveMemory,
|
||||
setupGlobalKeyboardShortcut,
|
||||
|
|
@ -20,13 +21,13 @@ export default defineContentScript({
|
|||
matches: ["<all_urls>"],
|
||||
main() {
|
||||
// Setup global event listeners
|
||||
browser.runtime.onMessage.addListener(async (message) => {
|
||||
browser.runtime.onMessage.addListener((message) => {
|
||||
if (message.action === MESSAGE_TYPES.SHOW_TOAST) {
|
||||
DOMUtils.showToast(message.state)
|
||||
} else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
|
||||
await saveMemory()
|
||||
return saveMemory(message.actionSource || "content_script")
|
||||
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
|
||||
await openImportModal()
|
||||
return openImportModal()
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
||||
updateTwitterImportUI(message)
|
||||
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
||||
|
|
@ -52,6 +53,9 @@ export default defineContentScript({
|
|||
if (DOMUtils.isOnDomain(DOMAINS.GROK)) {
|
||||
initializeGrok()
|
||||
}
|
||||
if (DOMUtils.isOnDomain(DOMAINS.GEMINI)) {
|
||||
initializeGemini()
|
||||
}
|
||||
if (DOMUtils.isOnDomain(DOMAINS.T3)) {
|
||||
initializeT3()
|
||||
}
|
||||
|
|
@ -70,6 +74,7 @@ export default defineContentScript({
|
|||
initializeChatGPT()
|
||||
initializeClaude()
|
||||
initializeGrok()
|
||||
initializeGemini()
|
||||
initializeT3()
|
||||
initializeTwitter()
|
||||
|
||||
|
|
|
|||
409
apps/browser-extension/entrypoints/content/memory-suggestion.ts
Normal file
409
apps/browser-extension/entrypoints/content/memory-suggestion.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
type SuggestionInput = HTMLElement | HTMLTextAreaElement
|
||||
|
||||
const SUGGESTION_ATTR = "data-supermemory-memory-suggestion"
|
||||
const SUPERMEMORY_PREFIX = "Supermemories of user (only for the reference):"
|
||||
const SUPERMEMORY_BLUE = "#1A88FF"
|
||||
|
||||
export function buildSupermemoryText(memories: unknown): string {
|
||||
const memoryText = Array.isArray(memories)
|
||||
? memories.join("").trim()
|
||||
: String(memories || "").trim()
|
||||
|
||||
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
|
||||
}
|
||||
|
||||
export function showMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
memories: unknown,
|
||||
): string {
|
||||
const suggestionText = buildSupermemoryText(memories)
|
||||
input.dataset.supermemories = suggestionText
|
||||
delete input.dataset.supermemoriesInjected
|
||||
|
||||
removeMemorySuggestion(platform)
|
||||
|
||||
const anchor = getSuggestionAnchor(input)
|
||||
if (!anchor) return suggestionText
|
||||
|
||||
const previousPosition = window.getComputedStyle(anchor).position
|
||||
if (previousPosition === "static") {
|
||||
anchor.dataset.supermemoryPreviousPosition = "static"
|
||||
anchor.style.position = "relative"
|
||||
}
|
||||
|
||||
const suggestion = createSuggestionContainer(platform, input, anchor)
|
||||
suggestion.dataset.supermemorySuggestionState = "ready"
|
||||
suggestion.style.gap = "8px"
|
||||
suggestion.style.alignItems = "center"
|
||||
|
||||
const text = document.createElement("span")
|
||||
text.style.cssText = `
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`
|
||||
text.textContent = suggestionText.trim()
|
||||
|
||||
const tabKey = document.createElement("span")
|
||||
tabKey.style.cssText = `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: ${SUPERMEMORY_BLUE};
|
||||
color: #FFFFFF;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset, 0 6px 18px rgba(26, 136, 255, 0.24);
|
||||
flex-shrink: 0;
|
||||
`
|
||||
tabKey.textContent = "Tab"
|
||||
|
||||
suggestion.appendChild(text)
|
||||
suggestion.appendChild(tabKey)
|
||||
anchor.appendChild(suggestion)
|
||||
|
||||
return suggestionText
|
||||
}
|
||||
|
||||
export function showLoadingSuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
) {
|
||||
removeMemorySuggestion(platform)
|
||||
|
||||
const anchor = getSuggestionAnchor(input)
|
||||
if (!anchor) return
|
||||
|
||||
const previousPosition = window.getComputedStyle(anchor).position
|
||||
if (previousPosition === "static") {
|
||||
anchor.dataset.supermemoryPreviousPosition = "static"
|
||||
anchor.style.position = "relative"
|
||||
}
|
||||
|
||||
ensureSuggestionAnimationStyle()
|
||||
|
||||
const suggestion = createSuggestionContainer(platform, input, anchor)
|
||||
suggestion.dataset.supermemorySuggestionState = "loading"
|
||||
suggestion.style.gap = "4px"
|
||||
suggestion.setAttribute("aria-label", "supermemory searching memories")
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const dot = document.createElement("span")
|
||||
dot.style.cssText = `
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: ${SUPERMEMORY_BLUE};
|
||||
animation: supermemorySuggestionDot 1s ease-in-out infinite;
|
||||
animation-delay: ${index * 0.14}s;
|
||||
`
|
||||
suggestion.appendChild(dot)
|
||||
}
|
||||
|
||||
anchor.appendChild(suggestion)
|
||||
}
|
||||
|
||||
function createSuggestionContainer(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
anchor: HTMLElement,
|
||||
): HTMLDivElement {
|
||||
const suggestion = document.createElement("div")
|
||||
suggestion.setAttribute(SUGGESTION_ATTR, platform)
|
||||
const position = getCaretPosition(input, anchor)
|
||||
const verticalOffset = platform === "gemini" ? -10 : 0
|
||||
suggestion.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${position.left + 6}px;
|
||||
top: ${position.top + verticalOffset}px;
|
||||
max-width: min(540px, calc(100% - ${position.left + 220}px));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
z-index: 2147483646;
|
||||
`
|
||||
return suggestion
|
||||
}
|
||||
|
||||
export function removeMemorySuggestion(platform: string) {
|
||||
const elements = document.querySelectorAll(
|
||||
`[${SUGGESTION_ATTR}="${platform}"]`,
|
||||
)
|
||||
for (const element of elements) {
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export function acceptMemorySuggestion(
|
||||
event: KeyboardEvent,
|
||||
platform: string,
|
||||
input: SuggestionInput | null,
|
||||
): boolean {
|
||||
if (event.key !== "Tab" || !input?.dataset.supermemories) {
|
||||
return false
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const text = input.dataset.supermemories
|
||||
appendTextToInput(input, text)
|
||||
delete input.dataset.supermemories
|
||||
input.dataset.supermemoriesInjected = "true"
|
||||
removeMemorySuggestion(platform)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasAcceptedSupermemoryContext(
|
||||
input: SuggestionInput | null,
|
||||
): boolean {
|
||||
if (!input) return false
|
||||
const text =
|
||||
input instanceof HTMLTextAreaElement
|
||||
? input.value
|
||||
: input.innerText || input.textContent || ""
|
||||
|
||||
return text.includes(SUPERMEMORY_PREFIX)
|
||||
}
|
||||
|
||||
export function syncAcceptedSupermemoryState(input: SuggestionInput | null) {
|
||||
if (!input?.dataset.supermemoriesInjected) return
|
||||
|
||||
if (!hasAcceptedSupermemoryContext(input)) {
|
||||
delete input.dataset.supermemoriesInjected
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput | null,
|
||||
) {
|
||||
removeMemorySuggestion(platform)
|
||||
if (input?.dataset.supermemories) {
|
||||
delete input.dataset.supermemories
|
||||
}
|
||||
if (input?.dataset.supermemoriesInjected) {
|
||||
delete input.dataset.supermemoriesInjected
|
||||
}
|
||||
}
|
||||
|
||||
export function setMemoryMarkerStatus(
|
||||
iconElement: HTMLElement | null,
|
||||
status: "neutral" | "searching" | "found" | "none" | "error",
|
||||
) {
|
||||
if (!iconElement) return
|
||||
|
||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
||||
|
||||
if (status === "neutral" || status === "none") {
|
||||
delete iconElement.dataset.supermemoryStatus
|
||||
return
|
||||
}
|
||||
|
||||
iconElement.dataset.supermemoryStatus = status
|
||||
const badge = document.createElement("span")
|
||||
badge.dataset.supermemoryStatusBadge = "true"
|
||||
badge.style.cssText = `
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: ${status === "searching" ? "7px" : "8px"};
|
||||
height: ${status === "searching" ? "7px" : "8px"};
|
||||
border-radius: 999px;
|
||||
background: ${status === "found" ? "#36F3D7" : status === "searching" ? SUPERMEMORY_BLUE : status === "error" ? "#EF4444" : "rgba(255, 255, 255, 0.55)"};
|
||||
border: 1px solid rgba(5, 7, 10, 0.9);
|
||||
box-shadow: ${status === "found" ? "0 0 0 2px rgba(54, 243, 215, 0.18)" : "none"};
|
||||
pointer-events: none;
|
||||
`
|
||||
iconElement.appendChild(badge)
|
||||
}
|
||||
|
||||
export function showMarkerPopover(
|
||||
iconElement: HTMLElement,
|
||||
message: string,
|
||||
memories?: string,
|
||||
resetAfter = 0,
|
||||
) {
|
||||
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
|
||||
ensureSuggestionAnimationStyle()
|
||||
|
||||
const popover = document.createElement("div")
|
||||
popover.dataset.supermemoryMarkerPopover = "true"
|
||||
popover.style.cssText = `
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 10px);
|
||||
min-width: 168px;
|
||||
max-width: 280px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 14, 20, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: #FAFAFA;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
z-index: 2147483647;
|
||||
pointer-events: auto;
|
||||
`
|
||||
|
||||
const title = document.createElement("div")
|
||||
title.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
margin-bottom: ${memories ? "8px" : "0"};
|
||||
`
|
||||
|
||||
if (message.toLowerCase().includes("searching")) {
|
||||
const dots = document.createElement("span")
|
||||
dots.style.cssText = "display: inline-flex; gap: 3px; align-items: center;"
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const dot = document.createElement("span")
|
||||
dot.style.cssText = `
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: ${SUPERMEMORY_BLUE};
|
||||
animation: supermemorySuggestionDot 1s ease-in-out infinite;
|
||||
animation-delay: ${index * 0.14}s;
|
||||
`
|
||||
dots.appendChild(dot)
|
||||
}
|
||||
title.appendChild(dots)
|
||||
}
|
||||
|
||||
const titleText = document.createElement("span")
|
||||
titleText.textContent =
|
||||
message === "Included Memories" ? "Included memories" : message
|
||||
title.appendChild(titleText)
|
||||
popover.appendChild(title)
|
||||
|
||||
if (memories) {
|
||||
const list = document.createElement("div")
|
||||
list.style.cssText = `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
`
|
||||
|
||||
memories
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
.slice(0, 5)
|
||||
.forEach((memory) => {
|
||||
const item = document.createElement("div")
|
||||
item.textContent = memory
|
||||
list.appendChild(item)
|
||||
})
|
||||
|
||||
popover.appendChild(list)
|
||||
}
|
||||
|
||||
iconElement.appendChild(popover)
|
||||
|
||||
if (resetAfter > 0) {
|
||||
setTimeout(() => {
|
||||
popover.remove()
|
||||
}, resetAfter)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSuggestionAnimationStyle() {
|
||||
if (document.getElementById("supermemory-suggestion-animation-style")) {
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = "supermemory-suggestion-animation-style"
|
||||
style.textContent = `
|
||||
@keyframes supermemorySuggestionDot {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-1px); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
function getSuggestionAnchor(input: SuggestionInput): HTMLElement | null {
|
||||
return (
|
||||
(input.closest("form") as HTMLElement | null) ||
|
||||
(input.closest('[role="textbox"]') as HTMLElement | null)?.parentElement ||
|
||||
input.parentElement
|
||||
)
|
||||
}
|
||||
|
||||
function getCaretPosition(input: SuggestionInput, anchor: HTMLElement) {
|
||||
const anchorRect = anchor.getBoundingClientRect()
|
||||
|
||||
if (!(input instanceof HTMLTextAreaElement)) {
|
||||
const selection = window.getSelection()
|
||||
if (selection?.rangeCount) {
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
if (input.contains(range.startContainer)) {
|
||||
range.collapse(true)
|
||||
let rect = range.getBoundingClientRect()
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const marker = document.createElement("span")
|
||||
marker.textContent = "\u200b"
|
||||
range.insertNode(marker)
|
||||
rect = marker.getBoundingClientRect()
|
||||
marker.remove()
|
||||
}
|
||||
|
||||
if (rect.width || rect.height) {
|
||||
return {
|
||||
left: Math.max(18, rect.right - anchorRect.left + 4),
|
||||
top: Math.max(10, rect.top - anchorRect.top),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
return {
|
||||
left: Math.max(18, inputRect.left - anchorRect.left + 18),
|
||||
top: Math.max(10, inputRect.top - anchorRect.top + 8),
|
||||
}
|
||||
}
|
||||
|
||||
function appendTextToInput(input: SuggestionInput, text: string) {
|
||||
if (input instanceof HTMLTextAreaElement) {
|
||||
input.value = `${input.value}${text}`
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
return
|
||||
}
|
||||
|
||||
input.focus()
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(input)
|
||||
range.collapse(false)
|
||||
range.insertNode(document.createTextNode(text))
|
||||
range.collapse(false)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
input.dispatchEvent(
|
||||
new InputEvent("input", { bubbles: true, inputType: "insertText" }),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import { MESSAGE_TYPES } from "../../utils/constants"
|
||||
import { bearerToken, userData } from "../../utils/storage"
|
||||
import type { APIResponse } from "../../utils/types"
|
||||
import { DOMUtils } from "../../utils/ui-components"
|
||||
import { default as TurndownService } from "turndown"
|
||||
|
||||
export async function saveMemory() {
|
||||
export async function saveMemory(
|
||||
actionSource = "content_script",
|
||||
): Promise<APIResponse> {
|
||||
try {
|
||||
DOMUtils.showToast("loading")
|
||||
|
||||
|
|
@ -64,21 +67,28 @@ export async function saveMemory() {
|
|||
data.markdown = markdown
|
||||
}
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
const response = (await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
data,
|
||||
actionSource: "context_menu",
|
||||
})
|
||||
actionSource,
|
||||
})) as APIResponse
|
||||
|
||||
console.log("Response from enxtension:", response)
|
||||
if (response.success) {
|
||||
if (response?.success) {
|
||||
DOMUtils.showToast("success")
|
||||
} else {
|
||||
DOMUtils.showToast("error")
|
||||
return response
|
||||
}
|
||||
DOMUtils.showToast("error")
|
||||
return {
|
||||
success: false,
|
||||
error: response?.error || "Failed to save memory",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving memory:", error)
|
||||
DOMUtils.showToast("error")
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +100,7 @@ export function setupGlobalKeyboardShortcut() {
|
|||
event.key === "m"
|
||||
) {
|
||||
event.preventDefault()
|
||||
await saveMemory()
|
||||
await saveMemory("keyboard_shortcut")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -110,9 +120,6 @@ export function setupStorageListener() {
|
|||
window.location.hostname === "app.supermemory.ai"
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
"Bearer token and user data is only allowed to be used on localhost or supermemory.ai",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export function initializeT3() {
|
|||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Adding supermemory icon to T3 input")
|
||||
addSupermemoryIconToT3Input()
|
||||
setupT3AutoFetch()
|
||||
}, 2000)
|
||||
|
|
@ -55,7 +54,6 @@ function setupT3RouteChangeDetection() {
|
|||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
console.log("T3 route changed, re-adding supermemory icon")
|
||||
setTimeout(() => {
|
||||
addSupermemoryIconToT3Input()
|
||||
setupT3AutoFetch()
|
||||
|
|
@ -183,10 +181,7 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("T3 query extracted:", userQuery)
|
||||
|
||||
if (!userQuery.trim()) {
|
||||
console.log("No query text found for T3")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +212,6 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
timeoutPromise,
|
||||
])
|
||||
|
||||
console.log("T3 memories response:", response)
|
||||
|
||||
if (response?.success && response?.data) {
|
||||
let textareaElement = null
|
||||
const supermemoryContainer = document.querySelector(
|
||||
|
|
@ -337,12 +330,10 @@ function updateT3IconFeedback(
|
|||
`
|
||||
|
||||
const memoriesText = iconElement.dataset.memoriesData || ""
|
||||
console.log("Memories text:", memoriesText)
|
||||
const individualMemories = memoriesText
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
console.log("Individual memories:", individualMemories)
|
||||
|
||||
individualMemories.forEach((memory, index) => {
|
||||
const memoryItem = document.createElement("div")
|
||||
|
|
@ -493,11 +484,10 @@ function setupT3PromptCapture() {
|
|||
}
|
||||
document.body.setAttribute("data-t3-prompt-capture-setup", "true")
|
||||
|
||||
const captureT3PromptContent = async (source: string) => {
|
||||
const captureT3PromptContent = async (_source: string) => {
|
||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
||||
|
||||
if (!autoCapture) {
|
||||
console.log("Auto capture prompts is disabled, skipping prompt capture")
|
||||
return
|
||||
}
|
||||
let promptContent = ""
|
||||
|
|
@ -538,15 +528,13 @@ function setupT3PromptCapture() {
|
|||
}
|
||||
|
||||
if (promptContent.trim()) {
|
||||
console.log(`T3 prompt submitted via ${source}:`, promptContent)
|
||||
|
||||
try {
|
||||
await browser.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
||||
data: {
|
||||
prompt: promptContent,
|
||||
platform: "t3",
|
||||
source: source,
|
||||
source: window.location.href,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ async function showOnboardingToast() {
|
|||
header.style.cssText =
|
||||
"display: flex; align-items: flex-start; gap: 12px; position: relative;"
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
const icon = document.createElement("img")
|
||||
icon.src = iconUrl
|
||||
icon.alt = "Supermemory"
|
||||
|
|
@ -512,7 +512,7 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) {
|
|||
animation: smSlideInUp 0.3s ease-out;
|
||||
`
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
const icon = document.createElement("img")
|
||||
icon.src = iconUrl
|
||||
icon.alt = "Supermemory"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { useQueryClient } from "@tanstack/react-query"
|
|||
import { useEffect, useState } from "react"
|
||||
import "./App.css"
|
||||
import { validateAuthToken } from "../../utils/api"
|
||||
import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants"
|
||||
import {
|
||||
getSupermemoryLoginUrl,
|
||||
MESSAGE_TYPES,
|
||||
STORAGE_KEYS,
|
||||
UI_CONFIG,
|
||||
} from "../../utils/constants"
|
||||
import {
|
||||
useDefaultProject,
|
||||
useProjects,
|
||||
|
|
@ -253,6 +258,7 @@ function App() {
|
|||
const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] =
|
||||
useState<boolean>(false)
|
||||
const [authInvalidated, setAuthInvalidated] = useState<boolean>(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: projects = [], isLoading: loadingProjects } = useProjects({
|
||||
|
|
@ -375,29 +381,70 @@ function App() {
|
|||
|
||||
const handleSaveCurrentPage = async () => {
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
if (tabs.length > 0 && tabs[0].id) {
|
||||
const response = await chrome.tabs.sendMessage(tabs[0].id, {
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
actionSource: "popup",
|
||||
})
|
||||
const tab = tabs[0]
|
||||
let response: { success?: boolean; error?: string } | undefined
|
||||
|
||||
if (response?.success) {
|
||||
await chrome.tabs.sendMessage(tabs[0].id, {
|
||||
action: MESSAGE_TYPES.SHOW_TOAST,
|
||||
state: "success",
|
||||
if (tab?.id) {
|
||||
try {
|
||||
response = await chrome.tabs.sendMessage(tab.id, {
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
actionSource: "popup",
|
||||
})
|
||||
} catch (contentScriptError) {
|
||||
console.warn("Content script save failed:", contentScriptError)
|
||||
}
|
||||
}
|
||||
|
||||
if (response && !response.success) {
|
||||
throw new Error(response.error || "Failed to save current page")
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
const fallbackUrl = tab?.url || currentUrl
|
||||
const fallbackTitle = tab?.title || currentTitle || "Current Page"
|
||||
|
||||
if (!fallbackUrl) {
|
||||
throw new Error("No active page URL found")
|
||||
}
|
||||
|
||||
response = await chrome.runtime.sendMessage({
|
||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
||||
actionSource: "popup_fallback",
|
||||
data: {
|
||||
url: fallbackUrl,
|
||||
title: fallbackTitle,
|
||||
content: `${fallbackTitle}\n\n${fallbackUrl}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
if (tab?.id) {
|
||||
await chrome.tabs
|
||||
.sendMessage(tab.id, {
|
||||
action: MESSAGE_TYPES.SHOW_TOAST,
|
||||
state: "success",
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
window.close()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(response?.error || "Failed to save current page")
|
||||
} catch (error) {
|
||||
console.error("Failed to save current page:", error)
|
||||
setSaveError(
|
||||
error instanceof Error ? error.message : "Could not save page",
|
||||
)
|
||||
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({
|
||||
|
|
@ -413,8 +460,6 @@ function App() {
|
|||
} catch (toastError) {
|
||||
console.error("Failed to show error toast:", toastError)
|
||||
}
|
||||
|
||||
window.close()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
|
@ -592,7 +637,7 @@ function App() {
|
|||
>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./icon-48.png"
|
||||
src="./new_logo.png"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -600,11 +645,9 @@ function App() {
|
|||
<span className="text-[11px] font-medium text-[#737373] leading-normal">
|
||||
Your
|
||||
</span>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./logo-fullmark.svg"
|
||||
className="h-[14.5px] w-auto"
|
||||
/>
|
||||
<span className="text-[15px] font-semibold leading-none text-white">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -658,7 +701,7 @@ function App() {
|
|||
>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./icon-48.png"
|
||||
src="./new_logo.png"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[29px] h-[29px]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -672,11 +715,9 @@ function App() {
|
|||
return name.endsWith("s") ? `${name}'` : `${name}'s`
|
||||
})()}
|
||||
</span>
|
||||
<img
|
||||
alt="supermemory"
|
||||
src="./logo-fullmark.svg"
|
||||
className="h-[14.5px] w-auto"
|
||||
/>
|
||||
<span className="text-[15px] font-semibold leading-none text-white">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{userSignedIn && (
|
||||
|
|
@ -931,6 +972,11 @@ function App() {
|
|||
|
||||
{saving ? "Saving..." : "Add to supermemory"}
|
||||
</button>
|
||||
{saveError && (
|
||||
<p className="mt-2 text-xs leading-snug text-red-300">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "imports" ? (
|
||||
|
|
@ -1269,13 +1315,13 @@ function App() {
|
|||
</h2>
|
||||
|
||||
<ul className="list-none p-0 m-0 text-left">
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['-'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
Save any page to your supermemory
|
||||
</li>
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['-'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
Import all your Twitter / X Bookmarks
|
||||
</li>
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['•'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
<li className="py-1.5 text-sm text-neutral-400 relative pl-5 before:content-['-'] before:absolute before:left-0 before:text-neutral-500 before:font-bold">
|
||||
Import your ChatGPT Memories
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -1300,9 +1346,7 @@ function App() {
|
|||
className="w-full py-3 px-6 bg-[#2d3f5c] text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-[#3d5270] disabled:bg-neutral-600 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: import.meta.env.PROD
|
||||
? "https://app.supermemory.ai/login"
|
||||
: "http://localhost:3000/login",
|
||||
url: getSupermemoryLoginUrl(),
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Popup Title</title>
|
||||
<title>supermemory</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,105 +1,114 @@
|
|||
import { getSupermemoryLoginUrl } from "../../utils/constants"
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Save any page",
|
||||
description: "Articles, docs, and references from the browser.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Import X bookmarks",
|
||||
description: "Bring saved posts into your memory library.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Capture AI chats",
|
||||
description: "Save useful conversations from ChatGPT, Claude, and Gemini.",
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Use context anywhere",
|
||||
description: "Search and reuse memories when you need them.",
|
||||
},
|
||||
]
|
||||
|
||||
function Welcome() {
|
||||
return (
|
||||
<div className="min-h-screen font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif] flex items-center justify-center p-8 bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="max-w-4xl w-full text-center">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<img
|
||||
alt="supermemory"
|
||||
className="h-16 mb-6 mx-auto"
|
||||
src="https://assets.supermemory.ai/brand/wordmark/dark-transparent.svg"
|
||||
/>
|
||||
<p className="text-gray-600 text-lg font-normal max-w-2xl mx-auto">
|
||||
Your AI second brain for saving and organizing everything that
|
||||
matters. Supermemory learns and remembers everything you save, your
|
||||
preferences, and understands you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#05080D] text-white font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif]">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #05080D 0%, #05070A 48%, #060A18 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.20)_1px,transparent_1px)] bg-size-[32px_32px] opacity-70 mask-[linear-gradient(to_bottom,transparent_0%,black_12%,black_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[55%] bg-[radial-gradient(ellipse_at_bottom,rgba(20,65,255,0.42),transparent_68%)]" />
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-black mb-8">
|
||||
What can you do with supermemory ?
|
||||
</h2>
|
||||
<main className="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-6 sm:px-10">
|
||||
<header className="flex items-center border-b border-white/10 pb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-8 rounded-[4px]" src="./new_logo.png" />
|
||||
<span className="text-lg font-semibold leading-none text-white">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
|
||||
<div className="text-3xl mb-4 block">💾</div>
|
||||
<h3 className="text-lg font-semibold text-black mb-3">
|
||||
Save Any Page
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-snug">
|
||||
Instantly save web pages, articles, and content to your personal
|
||||
knowledge base
|
||||
</p>
|
||||
</div>
|
||||
<section className="flex flex-1 flex-col items-center justify-center py-10 text-center">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="text-4xl font-semibold leading-[1.05] tracking-normal text-white sm:text-6xl">
|
||||
Your browser now has{" "}
|
||||
<span className="text-[#369BFD]">supermemory.</span>
|
||||
</h1>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
|
||||
<div className="text-3xl mb-4 block">🐦</div>
|
||||
<h3 className="text-lg font-semibold text-black mb-3">
|
||||
Import Twitter/X Bookmarks
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-snug">
|
||||
Bring all your saved tweets and bookmarks into one organized
|
||||
place
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
|
||||
<div className="text-3xl mb-4 block">🤖</div>
|
||||
<h3 className="text-lg font-semibold text-black mb-3">
|
||||
Import ChatGPT Memories
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-snug">
|
||||
Keep your important AI conversations and insights accessible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 text-center transition-all duration-200 shadow-sm hover:-translate-y-0.5 hover:shadow-md hover:border-gray-300">
|
||||
<div className="text-3xl mb-4 block">🔍</div>
|
||||
<h3 className="text-lg font-semibold text-black mb-3">
|
||||
Your context, everywhere.
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-snug">
|
||||
You can connect chatbots with MCP, chat with your personal
|
||||
assistant, and more.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<button
|
||||
className="h-12 rounded-xl px-7 text-sm font-semibold text-white transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#36fdfd]/70"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 18px 0 rgba(54, 155, 253, 0.24)",
|
||||
}}
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: getSupermemoryLoginUrl(),
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Sign in to connect
|
||||
</button>
|
||||
<button
|
||||
className="h-12 rounded-xl border border-[#369BFD]/25 bg-[#080B0F]/80 px-6 text-sm font-semibold text-[#C7D7F2] transition hover:border-[#369BFD]/50 hover:bg-[#0D121A] focus:outline-none focus:ring-2 focus:ring-[#369BFD]/30"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: "https://supermemory.ai",
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Open supermemory.ai
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
className="min-w-[200px] px-8 py-4 bg-gray-700 text-white border-none rounded-3xl text-base font-semibold cursor-pointer transition-colors duration-200 mb-4 outline-none hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
chrome.tabs.create({
|
||||
url: import.meta.env.PROD
|
||||
? "https://app.supermemory.ai/login"
|
||||
: "http://localhost:3000/login",
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Login to Get started
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-14 grid w-full max-w-5xl gap-3 text-left sm:grid-cols-2 lg:grid-cols-4">
|
||||
{featureCards.map((feature) => (
|
||||
<div
|
||||
className="rounded-lg border border-white/10 bg-white/[0.035] p-4"
|
||||
key={feature.number}
|
||||
>
|
||||
<p className="text-[11px] font-medium text-[#737373]">
|
||||
{feature.number}
|
||||
</p>
|
||||
<h2 className="mt-4 text-sm font-semibold text-white">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#A1A1AA]">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-200 pt-6 mt-8">
|
||||
<p className="text-sm text-gray-600">
|
||||
Learn more at{" "}
|
||||
<a
|
||||
className="text-blue-500 no-underline hover:underline hover:text-blue-700"
|
||||
href="https://supermemory.ai"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
supermemory.ai
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="border-t border-white/10 py-5 text-xs text-[#737373]">
|
||||
supermemory stores your extension session locally in Chrome.
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon-16.png" />
|
||||
<link rel="icon" type="image/png" href="/new_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to supermemory</title>
|
||||
</head>
|
||||
|
|
@ -10,4 +10,4 @@
|
|||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
BIN
apps/browser-extension/public/new_logo.png
Normal file
BIN
apps/browser-extension/public/new_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -4,5 +4,6 @@
|
|||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["chrome"]
|
||||
}
|
||||
},
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
import { API_ENDPOINTS } from "./constants"
|
||||
import { bearerToken, defaultProject, userData } from "./storage"
|
||||
import { buildSearchMemoriesBody } from "./search-request"
|
||||
import {
|
||||
AuthenticationError,
|
||||
type MemoryPayload,
|
||||
|
|
@ -145,14 +146,14 @@ export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
|
|||
/**
|
||||
* Search memories using Supermemory API
|
||||
*/
|
||||
export async function searchMemories(query: string): Promise<unknown> {
|
||||
export async function searchMemories(
|
||||
query: string,
|
||||
containerTag?: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: query,
|
||||
include: { relatedMemories: true },
|
||||
}),
|
||||
body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)),
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,17 @@ export const API_ENDPOINTS = {
|
|||
: "http://localhost:3000",
|
||||
} as const
|
||||
|
||||
export function getSupermemoryLoginUrl(): string {
|
||||
const baseUrl = API_ENDPOINTS.SUPERMEMORY_WEB
|
||||
const loginUrl = new URL("/login", baseUrl)
|
||||
const redirectUrl = new URL("/", baseUrl)
|
||||
|
||||
redirectUrl.searchParams.set("extension-auth-success", "true")
|
||||
loginUrl.searchParams.set("redirect", redirectUrl.toString())
|
||||
|
||||
return loginUrl.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM Element IDs
|
||||
*/
|
||||
|
|
@ -22,6 +33,7 @@ export const ELEMENT_IDS = {
|
|||
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
|
||||
CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element",
|
||||
CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element",
|
||||
GEMINI_INPUT_BAR_ELEMENT: "sm-gemini-input-bar-element",
|
||||
T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element",
|
||||
PROJECT_SELECTION_MODAL: "sm-project-selection-modal",
|
||||
} as const
|
||||
|
|
@ -59,6 +71,7 @@ export const DOMAINS = {
|
|||
CHATGPT: ["chatgpt.com", "chat.openai.com"],
|
||||
CLAUDE: ["claude.ai"],
|
||||
GROK: ["grok.com", "x.ai"],
|
||||
GEMINI: ["gemini.google.com"],
|
||||
T3: ["t3.chat"],
|
||||
SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
|
||||
} as const
|
||||
|
|
@ -95,6 +108,8 @@ export const POSTHOG_EVENT_KEY = {
|
|||
T3_CHAT_MEMORIES_AUTO_SEARCHED: "t3_chat_memories_auto_searched",
|
||||
CLAUDE_CHAT_MEMORIES_SEARCHED: "claude_chat_memories_searched",
|
||||
CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched",
|
||||
GEMINI_CHAT_MEMORIES_SEARCHED: "gemini_chat_memories_searched",
|
||||
GEMINI_CHAT_MEMORIES_AUTO_SEARCHED: "gemini_chat_memories_auto_searched",
|
||||
CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched",
|
||||
CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched",
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export function createRouteDetection(
|
|||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href
|
||||
console.log(`${config.platform} route changed, re-initializing`)
|
||||
setTimeout(config.reinitCallback, 1000)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
apps/browser-extension/utils/search-request.test.ts
Normal file
19
apps/browser-extension/utils/search-request.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { buildSearchMemoriesBody } from "./search-request"
|
||||
|
||||
describe("buildSearchMemoriesBody", () => {
|
||||
it("builds the default related-memory search body", () => {
|
||||
expect(buildSearchMemoriesBody("deploy notes")).toEqual({
|
||||
q: "deploy notes",
|
||||
include: { relatedMemories: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("includes the container tag when provided", () => {
|
||||
expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({
|
||||
q: "deploy notes",
|
||||
include: { relatedMemories: true },
|
||||
containerTag: "sm_project_docs",
|
||||
})
|
||||
})
|
||||
})
|
||||
14
apps/browser-extension/utils/search-request.ts
Normal file
14
apps/browser-extension/utils/search-request.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function buildSearchMemoriesBody(
|
||||
query: string,
|
||||
containerTag?: string,
|
||||
): {
|
||||
q: string
|
||||
include: { relatedMemories: boolean }
|
||||
containerTag?: string
|
||||
} {
|
||||
return {
|
||||
q: query,
|
||||
include: { relatedMemories: true },
|
||||
...(containerTag ? { containerTag } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@ export async function captureTwitterTokens(
|
|||
if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
|
||||
const tokensAlreadyLogged = await getTokensLogged()
|
||||
if (!tokensAlreadyLogged) {
|
||||
console.log("Twitter auth tokens captured successfully")
|
||||
await setTokensLogged()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,8 +186,6 @@ export class TwitterImporter {
|
|||
if (documents.length > 0) {
|
||||
await saveAllTweets(documents)
|
||||
}
|
||||
console.log("Tweets saved")
|
||||
console.log("Documents:", documents)
|
||||
} catch (error) {
|
||||
console.error("Error saving tweets batch:", error)
|
||||
await this.config.onError(error as Error)
|
||||
|
|
@ -201,9 +199,6 @@ export class TwitterImporter {
|
|||
[]
|
||||
const nextCursor = extractNextCursor(instructions)
|
||||
|
||||
console.log("Next cursor:", nextCursor)
|
||||
console.log("Tweets length:", tweets.length)
|
||||
|
||||
if (nextCursor && tweets.length > 0 && !this.config.isFolderImport) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting
|
||||
await this.batchImportAll(nextCursor, importedCount, uniqueGroupId)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ export interface MemoryData {
|
|||
url?: string
|
||||
ogImage?: string
|
||||
title?: string
|
||||
sourcePlatform?: string
|
||||
sourcePlatformLabel?: string
|
||||
sourceSurface?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export function createToast(state: ToastState): HTMLElement {
|
|||
break
|
||||
|
||||
case "success": {
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
|
||||
textElement.textContent = "Added to Memory"
|
||||
break
|
||||
|
|
@ -184,7 +184,7 @@ export function createTwitterImportButton(onClick: () => void): HTMLElement {
|
|||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
`
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
|
||||
button.style.backgroundImage = `url("${iconUrl}")`
|
||||
button.style.backgroundRepeat = "no-repeat"
|
||||
|
|
@ -232,7 +232,7 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement {
|
|||
z-index: 1000;
|
||||
`
|
||||
|
||||
const iconFileName = "/icon-16.png"
|
||||
const iconFileName = "/new_logo.png"
|
||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
||||
iconButton.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
|
||||
|
|
@ -261,31 +261,72 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement {
|
|||
* @returns HTMLElement - The save button element
|
||||
*/
|
||||
export function createChatGPTInputBarElement(onClick: () => void): HTMLElement {
|
||||
const iconButton = document.createElement("div")
|
||||
return createConnectedIndicator(onClick)
|
||||
}
|
||||
|
||||
export function createConnectedIndicator(onClick: () => void): HTMLElement {
|
||||
const iconButton = document.createElement("button")
|
||||
iconButton.type = "button"
|
||||
iconButton.setAttribute("aria-label", "supermemory connected")
|
||||
iconButton.dataset.supermemoryConnectedIndicator = "true"
|
||||
iconButton.style.cssText = `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
// Use appropriate icon based on theme
|
||||
const iconFileName = "/icon-16.png"
|
||||
const iconFileName = "/new_logo.png"
|
||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
||||
iconButton.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 50%;" />
|
||||
<img src="${iconUrl}" width="20" height="20" alt="" style="border-radius: 5px; display: block;" />
|
||||
`
|
||||
|
||||
const tooltip = document.createElement("div")
|
||||
tooltip.textContent = "supermemory connected"
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
background: #0A0E14;
|
||||
color: #FAFAFA;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
z-index: 2147483647;
|
||||
`
|
||||
iconButton.appendChild(tooltip)
|
||||
|
||||
iconButton.addEventListener("mouseenter", () => {
|
||||
iconButton.style.opacity = "0.8"
|
||||
iconButton.style.backgroundColor = "rgba(255, 255, 255, 0.08)"
|
||||
tooltip.style.opacity = "1"
|
||||
tooltip.style.transform = "translateX(-50%) translateY(0)"
|
||||
})
|
||||
|
||||
iconButton.addEventListener("mouseleave", () => {
|
||||
iconButton.style.opacity = "1"
|
||||
iconButton.style.backgroundColor = "transparent"
|
||||
tooltip.style.opacity = "0"
|
||||
tooltip.style.transform = "translateX(-50%) translateY(2px)"
|
||||
})
|
||||
|
||||
iconButton.addEventListener("click", (event) => {
|
||||
|
|
@ -303,42 +344,11 @@ export function createChatGPTInputBarElement(onClick: () => void): HTMLElement {
|
|||
* @returns HTMLElement - The save button element
|
||||
*/
|
||||
export function createClaudeInputBarElement(onClick: () => void): HTMLElement {
|
||||
const iconButton = document.createElement("div")
|
||||
iconButton.style.cssText = `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
`
|
||||
return createConnectedIndicator(onClick)
|
||||
}
|
||||
|
||||
const iconFileName = "/icon-16.png"
|
||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
||||
iconButton.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Get Related Memories from supermemory" style="border-radius: 4px;" />
|
||||
`
|
||||
|
||||
iconButton.addEventListener("mouseenter", () => {
|
||||
iconButton.style.backgroundColor = "rgba(0, 0, 0, 0.05)"
|
||||
iconButton.style.borderColor = "rgba(0, 0, 0, 0.2)"
|
||||
})
|
||||
|
||||
iconButton.addEventListener("mouseleave", () => {
|
||||
iconButton.style.backgroundColor = "transparent"
|
||||
iconButton.style.borderColor = "rgba(0, 0, 0, 0.1)"
|
||||
})
|
||||
|
||||
iconButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
})
|
||||
|
||||
return iconButton
|
||||
export function createGeminiInputBarElement(onClick: () => void): HTMLElement {
|
||||
return createConnectedIndicator(onClick)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -360,7 +370,7 @@ export function createT3InputBarElement(onClick: () => void): HTMLElement {
|
|||
background: transparent;
|
||||
`
|
||||
|
||||
const iconFileName = "/icon-16.png"
|
||||
const iconFileName = "/new_logo.png"
|
||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
||||
iconButton.innerHTML = `
|
||||
<img src="${iconUrl}" width="20" height="20" alt="Get Related Memories from supermemory" style="border-radius: 4px;" />
|
||||
|
|
@ -433,7 +443,7 @@ export function createProjectSelectionModal(
|
|||
margin-bottom: 20px;
|
||||
`
|
||||
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
header.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #ffffff; display: flex; align-items: center; gap: 8px;">
|
||||
|
|
@ -702,7 +712,7 @@ export const DOMUtils = {
|
|||
|
||||
if (icon && text) {
|
||||
if (state === "success") {
|
||||
const iconUrl = browser.runtime.getURL("/icon-16.png")
|
||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
||||
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
|
||||
icon.style.animation = ""
|
||||
text.textContent = "Added to Memory"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default defineConfig({
|
|||
manifest: {
|
||||
name: "supermemory",
|
||||
homepage_url: "https://supermemory.ai",
|
||||
version: "6.1.4",
|
||||
version: "6.1.3",
|
||||
permissions: ["storage", "activeTab", "webRequest", "tabs"],
|
||||
host_permissions: [
|
||||
"*://x.com/*",
|
||||
|
|
@ -42,11 +42,14 @@ export default defineConfig({
|
|||
"*://*.grok.com/*",
|
||||
"*://x.ai/*",
|
||||
"*://*.x.ai/*",
|
||||
"*://claude.ai/*",
|
||||
"*://gemini.google.com/*",
|
||||
"*://t3.chat/*",
|
||||
"https://*.posthog.com/*",
|
||||
],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ["icon-16.png", "fonts/*.ttf"],
|
||||
resources: ["new_logo.png", "fonts/*.ttf"],
|
||||
matches: ["<all_urls>"],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ icon: "mail"
|
|||
Connect Gmail to automatically sync email threads into your supermemory knowledge base. Supports real-time updates via Google Cloud Pub/Sub webhooks and incremental synchronization.
|
||||
|
||||
<Note>
|
||||
**Scale Plan Required:** The Gmail connector is available on Scale and Enterprise plans only.
|
||||
**Max Plan Required:** The Gmail connector is available on Max plan and above.
|
||||
</Note>
|
||||
|
||||
## Quick Setup
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@
|
|||
"self-hosting/overview",
|
||||
"self-hosting/quickstart",
|
||||
"self-hosting/configuration",
|
||||
"self-hosting/embeddings",
|
||||
"self-hosting/providers",
|
||||
"self-hosting/local-vs-enterprise"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ const model = withSupermemory(openai("gpt-5"), {
|
|||
})
|
||||
```
|
||||
|
||||
### Persisting Tool Calls (default: off)
|
||||
|
||||
By default, saved conversations include only user and assistant text — tool calls and tool results are dropped, since tool payloads are often large and low-signal and would pollute memory extraction. To persist the full tool round trip (tool calls with their arguments, plus tool results, in their original order), set `includeToolCalls: true`:
|
||||
|
||||
```typescript
|
||||
const model = withSupermemory(openai("gpt-5"), {
|
||||
containerTag: "user-123",
|
||||
customId: "conv-1",
|
||||
includeToolCalls: true,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Tools
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: "Every environment variable the self-hosted server understands."
|
|||
icon: "settings"
|
||||
---
|
||||
|
||||
The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it.
|
||||
The self-hosted server aims for **zero configuration** — the only required input is one LLM provider key, which the first-boot wizard collects interactively (or set via env var for non-interactive deployments). Embeddings default to local English; you can pick another provider in the optional wizard step or via env. Everything else below is opt-in.
|
||||
|
||||
The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager.
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ The installer writes API keys to `~/.supermemory/env`, which is loaded on every
|
|||
|
||||
## LLM providers
|
||||
|
||||
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**:
|
||||
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own LLM for the intelligent steps — summaries, contextual chunking, and memory extraction. Embeddings default to a local model (no API key) and can optionally use OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). Configure **at least one** LLM provider:
|
||||
|
||||
| Variable | Provider |
|
||||
|---|---|
|
||||
|
|
@ -61,43 +61,20 @@ OPENAI_MODEL=gpt-oss:20b
|
|||
|
||||
Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
|
||||
|
||||
## Embedding models
|
||||
## Embeddings
|
||||
|
||||
By default, embeddings run on the bundled local model (`Xenova/bge-base-en-v1.5`, 768-dim, English-only) — no network, no key. Swap in a remote provider if you need multilingual coverage or want to match embeddings you already generate elsewhere:
|
||||
By default, vectors are computed locally with `Xenova/bge-base-en-v1.5` (768d) — no embedding API key. On interactive first boot you can pick a different provider after the LLM key step; for Docker/CI set env vars instead.
|
||||
|
||||
Full provider table, multilingual guidance, remote examples (OpenAI / Gemini / Ollama), and the re-ingestion / dimension-lock warning: **[Embeddings (self-hosted)](/self-hosting/embeddings)**.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local` \| `openai` \| `openai-compatible` \| `google` | `local` |
|
||||
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id — HF id for `local`, provider model id for remote | `Xenova/bge-base-en-v1.5` |
|
||||
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Output dimensionality. Required for models not in the known-dimension list (e.g. `text-embedding-3-large`) | inferred where possible |
|
||||
| `SUPERMEMORY_EMBEDDING_API_KEY` | Remote provider key | falls back to `OPENAI_API_KEY` / `GEMINI_API_KEY` |
|
||||
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Remote endpoint URL | `openai-compatible` also falls back to `OPENAI_BASE_URL`; plain `openai` does not |
|
||||
| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local`, `openai`, `gemini`, or OpenAI-compatible remote | `local` |
|
||||
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` |
|
||||
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match model and stored data | `768` |
|
||||
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs | unset |
|
||||
|
||||
```bash
|
||||
# Multilingual local model — swap the HF id, dimensions are inferred
|
||||
SUPERMEMORY_EMBEDDING_MODEL=Xenova/multilingual-e5-large
|
||||
|
||||
# OpenAI embeddings
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# Any OpenAI-compatible embedding endpoint (e.g. Ollama)
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai-compatible
|
||||
SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Gemini embeddings
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=google
|
||||
SUPERMEMORY_EMBEDDING_MODEL=gemini-embedding-001
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Changing the model or dimensionality after you've already ingested data requires re-ingestion — the store enforces one embedding dimension at a time, and a boot-time check fails fast if the configured dimension doesn't match what's already there. Only reshape a fresh, empty store.
|
||||
</Warning>
|
||||
|
||||
pgvector's HNSW index caps out at 2000 dimensions. `text-embedding-3-large` (3072-dim natively) needs `SUPERMEMORY_EMBEDDING_DIMENSIONS` set to something ≤2000 to fit — the `text-embedding-3-*` family and Gemini support this kind of output reduction; models like `text-embedding-ada-002` don't and must run at their native size.
|
||||
|
||||
## Embedding performance
|
||||
### 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 (remote embedding providers ignore these — there's no local worker pool to tune):
|
||||
|
||||
|
|
@ -164,8 +141,13 @@ Any other environment variables you may find referenced in the codebase are plat
|
|||
# Persistent data location
|
||||
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
|
||||
|
||||
# One LLM provider
|
||||
# One LLM provider (required for extraction)
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Optional — omit to keep local Xenova/bge-base-en-v1.5 (768d)
|
||||
# SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
# SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
|
||||
```
|
||||
|
||||
That's enough for full ingestion, memory extraction, and hybrid search.
|
||||
That's enough for full ingestion, memory extraction, and hybrid search with the default local embeddings.
|
||||
|
|
|
|||
135
apps/docs/self-hosting/embeddings.mdx
Normal file
135
apps/docs/self-hosting/embeddings.mdx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
title: "Embeddings (self-hosted)"
|
||||
sidebarTitle: "Embeddings"
|
||||
description: "Local and remote embedding providers for Supermemory local — defaults, env vars, multilingual options, and dimension lock."
|
||||
icon: "waypoints"
|
||||
---
|
||||
|
||||
Self-hosted Supermemory uses the **same embedding provider stack** as the hosted platform: local ONNX models, OpenAI, Gemini, or any OpenAI-compatible embeddings endpoint (including Ollama). LLM keys power extraction and summarization; embeddings are configured separately.
|
||||
|
||||
## Defaults
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Provider | `local` |
|
||||
| Model | `Xenova/bge-base-en-v1.5` |
|
||||
| Dimensions | `768` |
|
||||
| API key | None — runs on your machine |
|
||||
|
||||
Press Enter at the optional first-boot picker to keep this default. Nothing is sent off-box to embed.
|
||||
|
||||
<Warning>
|
||||
The default local model is **English-only**. Non-English content can ingest successfully while dense semantic recall stays weak. See [Multilingual](#multilingual).
|
||||
</Warning>
|
||||
|
||||
## First-time setup (interactive)
|
||||
|
||||
On first boot with a TTY, Supermemory asks for an LLM API key (required), then optionally which embedding model to use.
|
||||
|
||||
1. Choose or paste an LLM provider key (OpenAI, Anthropic, Gemini, Groq, or OpenAI-compatible).
|
||||
2. Optionally pick an embedding provider/model. **Press Enter to keep the local English model.**
|
||||
3. Choices are saved encrypted under your data directory (`$SUPERMEMORY_DATA_DIR`, typically `./.supermemory` / `~/.supermemory`).
|
||||
|
||||
Boot order is intentional: LLM keys load first so remote embedding options can reuse them (for example OpenAI or Gemini embeddings with the same key).
|
||||
|
||||
<Tip>
|
||||
**First boot (terminal):** Supermemory asks for an LLM API key (required), then optionally which embedding model to use. Press Enter to keep the local English model. Choices are saved encrypted under your data directory.
|
||||
</Tip>
|
||||
|
||||
## Configuration (env)
|
||||
|
||||
For Docker, CI, or any non-interactive deploy, set env vars — there is **no interactive prompt without a TTY**.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_EMBEDDING_PROVIDER` | Embedding backend: `local`, `openai`, `gemini`, or an OpenAI-compatible remote (`ollama` / custom base URL) | `local` |
|
||||
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` (local) |
|
||||
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match the model and any already-stored data | `768` (local default) |
|
||||
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs (Ollama, vLLM, etc.) | unset |
|
||||
| `OPENAI_API_KEY` | Used when provider is `openai` (or compatible) if not otherwise supplied | unset |
|
||||
| `GEMINI_API_KEY` | Used when provider is `gemini` | unset |
|
||||
|
||||
Local worker tuning (throughput only — does not change model or dimensions):
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
|
||||
| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
|
||||
| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
|
||||
|
||||
Ingestion memory headroom is controlled by `SUPERMEMORY_EMBEDDING_RAM_LIMIT` — see [Memory limits & ingestion queue](/self-hosting/configuration#memory-limits--ingestion-queue).
|
||||
|
||||
<Tip>
|
||||
**Docker / production:** Set at least one LLM key and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `SUPERMEMORY_EMBEDDING_MODEL` / `SUPERMEMORY_EMBEDDING_DIMENSIONS` (and base URL or API key as needed). There is no interactive prompt without a TTY.
|
||||
</Tip>
|
||||
|
||||
## Multilingual
|
||||
|
||||
The default `Xenova/bge-base-en-v1.5` model is trained for English. For German, Dutch, and other non-English corpora, dense recall can fail even when hybrid keyword search still finds rare tokens.
|
||||
|
||||
For multilingual or non-English deployments, switch **before** large backfills:
|
||||
|
||||
```bash
|
||||
# Example: local multilingual (set dimensions to match the model)
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=local
|
||||
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-m3
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=1024
|
||||
```
|
||||
|
||||
Or use a remote multilingual embedding API (OpenAI, Gemini, or Ollama with a multilingual embed model). Set provider, model, and dimensions together. Changing them later requires a fresh data directory or full re-ingestion — see below.
|
||||
|
||||
## Remote providers
|
||||
|
||||
### Local (default)
|
||||
|
||||
```bash
|
||||
# Explicit local default — no embedding API key
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=local
|
||||
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
### OpenAI
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
|
||||
```
|
||||
|
||||
### Gemini
|
||||
|
||||
```bash
|
||||
GEMINI_API_KEY=...
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=gemini
|
||||
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-004
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
### Ollama (OpenAI-compatible)
|
||||
|
||||
```bash
|
||||
SUPERMEMORY_EMBEDDING_PROVIDER=openai
|
||||
SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1
|
||||
OPENAI_API_KEY=ollama
|
||||
SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text
|
||||
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
|
||||
```
|
||||
|
||||
Use the dimension published for your chosen model. A mismatch with vectors already in the store fails boot.
|
||||
|
||||
## Changing models later
|
||||
|
||||
<Warning>
|
||||
**Not supported in place.** Embeddings from different models (or different dimensions) are not comparable. Start from a fresh data directory or re-ingest all content so vectors stay in one space. If configured dimensions disagree with stored data, the server **refuses to boot**.
|
||||
</Warning>
|
||||
|
||||
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
|
||||
- [Quickstart](/self-hosting/quickstart) — install and first memory
|
||||
|
|
@ -24,7 +24,7 @@ No Docker. No database to provision. No config files. It boots in seconds with e
|
|||
Run the binary with nothing set and you get a complete memory system:
|
||||
|
||||
- **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings.
|
||||
- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded.
|
||||
- **Built-in local embeddings** — default `Xenova/bge-base-en-v1.5` (768d) on your machine, no API key. Same provider stack as cloud if you opt into OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings).
|
||||
- **An API key, generated for you** — printed on first boot, ready to paste into any SDK.
|
||||
- **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works.
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ Self-hosted is free, open source, and great for local development, air-gapped en
|
|||
|---|---|---|
|
||||
| Full Memory API | ✅ | ✅ |
|
||||
| Hybrid semantic search | ✅ | ✅ |
|
||||
| Local embeddings | ✅ | Managed |
|
||||
| Embeddings | Local default (or OpenAI / Gemini / Ollama) | Same provider stack, managed |
|
||||
| File ingestion (PDFs, images) | ✅ | ✅ |
|
||||
| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
|
||||
| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
|
||||
|
|
@ -75,11 +75,14 @@ If you outgrow a single machine — or want connectors, MCP, and the best-tuned
|
|||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Quickstart" icon="play" href="/self-hosting/quickstart">
|
||||
Install, run, and store your first memory in under two minutes
|
||||
</Card>
|
||||
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
|
||||
Every environment variable: LLM providers, storage, auth, tuning
|
||||
</Card>
|
||||
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
|
||||
Local default, remote providers, multilingual, dimension lock
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
|
|
|||
|
|
@ -47,9 +47,13 @@ First boot sets everything up — the embedded Supermemory graph engine, local e
|
|||
Save that API key — it's your bearer token for every request.
|
||||
|
||||
<Note>
|
||||
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
|
||||
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. After the LLM key, you can optionally pick an embedding model (press Enter to keep local `Xenova/bge-base-en-v1.5`). See [all providers](/self-hosting/configuration#llm-providers), [embeddings](/self-hosting/embeddings), and [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
**Docker / non-interactive:** set an LLM key via env and, if you don’t want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `MODEL` / `DIMENSIONS`. There is no wizard without a TTY.
|
||||
</Tip>
|
||||
|
||||
## Add your first memory
|
||||
|
||||
<Tabs>
|
||||
|
|
@ -141,10 +145,13 @@ By default, all state lives in a single directory you can back up or move:
|
|||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
|
||||
LLM providers, local models, performance tuning
|
||||
</Card>
|
||||
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
|
||||
Local default, OpenAI / Gemini / Ollama, multilingual
|
||||
</Card>
|
||||
<Card title="Memory API" icon="book-open" href="/quickstart">
|
||||
The full API — it all works against your local server
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -106,6 +106,24 @@ Search memories and get user profile.
|
|||
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
|
||||
| `containerTag` | string | No | Project tag to scope the search |
|
||||
|
||||
### `listMemories`
|
||||
|
||||
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"containerTag": "optional-project-tag"
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `page` | integer | No | Page number (1-based). Default: `1` |
|
||||
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
|
||||
| `containerTag` | string | No | Project tag to scope the listing |
|
||||
|
||||
### `whoAmI`
|
||||
|
||||
Get the current logged-in user's information.
|
||||
|
|
@ -186,6 +204,7 @@ bun run test:e2e
|
|||
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
|
||||
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
|
||||
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
|
||||
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
|
||||
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
|
||||
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,34 @@ import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
|
|||
const EXPECTED_TOOLS = [
|
||||
"memory",
|
||||
"recall",
|
||||
"listMemories",
|
||||
"listProjects",
|
||||
"whoAmI",
|
||||
"memory-graph",
|
||||
]
|
||||
|
||||
const READ_ONLY_TOOL_NAMES = [
|
||||
"recall",
|
||||
"listMemories",
|
||||
"listProjects",
|
||||
"whoAmI",
|
||||
"memory-graph",
|
||||
]
|
||||
|
||||
const READ_ONLY_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
}
|
||||
|
||||
const MEMORY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
||||
let s: Session
|
||||
|
||||
|
|
@ -25,6 +48,20 @@ describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
|
|||
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
|
||||
})
|
||||
|
||||
it("marks read-only tools as non-destructive", async () => {
|
||||
const { tools } = await s.client.listTools()
|
||||
for (const name of READ_ONLY_TOOL_NAMES) {
|
||||
const tool = tools.find((t) => t.name === name)
|
||||
expect(tool?.annotations).toMatchObject(READ_ONLY_ANNOTATIONS)
|
||||
}
|
||||
})
|
||||
|
||||
it("marks memory as mutating", async () => {
|
||||
const { tools } = await s.client.listTools()
|
||||
const memory = tools.find((t) => t.name === "memory")
|
||||
expect(memory?.annotations).toMatchObject(MEMORY_TOOL_ANNOTATIONS)
|
||||
})
|
||||
|
||||
it("lists profile & projects resources", async () => {
|
||||
const { resources } = await s.client.listResources()
|
||||
const uris = resources.map((r) => r.uri)
|
||||
|
|
|
|||
76
apps/mcp/e2e/list-memories.test.ts
Normal file
76
apps/mcp/e2e/list-memories.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import {
|
||||
API_KEY,
|
||||
callTool,
|
||||
connect,
|
||||
type Session,
|
||||
sleep,
|
||||
textOf,
|
||||
} from "./helpers"
|
||||
|
||||
// listMemories reads extracted memory entries, which appear only after the
|
||||
// async ingestion pipeline finishes — poll like recallUntil does.
|
||||
async function listUntil(
|
||||
s: Session,
|
||||
needle: string,
|
||||
{ tries = 18, delayMs = 5000 } = {},
|
||||
): Promise<string | null> {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
// The marker document is the newest, so page 1 is enough.
|
||||
const res = await callTool(s.client, "listMemories", { limit: 20 })
|
||||
const txt = textOf(res)
|
||||
if (txt.includes(needle)) return txt
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
|
||||
let s: Session
|
||||
const created: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
s = await connect()
|
||||
})
|
||||
afterAll(async () => {
|
||||
for (const content of created) {
|
||||
await callTool(s.client, "memory", {
|
||||
content,
|
||||
action: "forget",
|
||||
}).catch(() => {})
|
||||
}
|
||||
await s?.close()
|
||||
})
|
||||
|
||||
it("lists a saved memory without dumping document content", async () => {
|
||||
const marker = `lm-${randomUUID()}`
|
||||
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
|
||||
created.push(content)
|
||||
|
||||
const save = await callTool(s.client, "memory", { content, action: "save" })
|
||||
expect(save.isError).toBeFalsy()
|
||||
|
||||
const listing = await listUntil(s, marker)
|
||||
expect(
|
||||
listing,
|
||||
`listMemories never returned marker ${marker}`,
|
||||
).not.toBeNull()
|
||||
// Header shape: "N memories across M documents (page X of Y, ...)"
|
||||
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
|
||||
}, 120_000)
|
||||
|
||||
it("paginates with a bounded page size", async () => {
|
||||
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
|
||||
expect(res.isError).toBeFalsy()
|
||||
const txt = textOf(res)
|
||||
// With the memory saved above there is at least one document.
|
||||
expect(txt).toMatch(/page 1 of \d+/)
|
||||
}, 30_000)
|
||||
|
||||
it("rejects an out-of-range limit", async () => {
|
||||
const res = await callTool(s.client, "listMemories", { limit: 500 })
|
||||
// Zod schema caps limit at 50 — the SDK surfaces this as a tool error.
|
||||
expect(res.isError).toBeTruthy()
|
||||
}, 30_000)
|
||||
})
|
||||
193
apps/mcp/src/format.test.ts
Normal file
193
apps/mcp/src/format.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import type { DocumentsApiResponse } from "./client"
|
||||
import { formatMemoriesList } from "./format"
|
||||
|
||||
function makeResponse(
|
||||
overrides: Partial<DocumentsApiResponse> = {},
|
||||
): DocumentsApiResponse {
|
||||
return {
|
||||
documents: [],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `mem_${memory.slice(0, 8)}`,
|
||||
memory,
|
||||
spaceId: "space_1",
|
||||
createdAt: "2026-06-10T12:00:00Z",
|
||||
updatedAt: "2026-06-10T12:00:00Z",
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatMemoriesList", () => {
|
||||
it("reports an empty store", () => {
|
||||
expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.")
|
||||
})
|
||||
|
||||
it("reports an out-of-range page distinctly from an empty store", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
pagination: {
|
||||
currentPage: 3,
|
||||
limit: 10,
|
||||
totalItems: 12,
|
||||
totalPages: 2,
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toBe("No documents on page 3 (2 pages total).")
|
||||
})
|
||||
|
||||
it("groups memories under their source document with title, type, and date", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Preferences",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("User prefers dark mode"),
|
||||
makeEntry("User works in TypeScript"),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain(
|
||||
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
|
||||
)
|
||||
expect(result).toContain('"Preferences" (text, 2026-06-12)')
|
||||
expect(result).toContain("- User prefers dark mode")
|
||||
expect(result).toContain("- User works in TypeScript")
|
||||
expect(result).not.toContain("More available")
|
||||
})
|
||||
|
||||
it("excludes forgotten and superseded memory entries", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Facts",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("Current fact"),
|
||||
makeEntry("Forgotten fact", { isForgotten: true }),
|
||||
makeEntry("Old version of a fact", { isLatest: false }),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("- Current fact")
|
||||
expect(result).not.toContain("Forgotten fact")
|
||||
expect(result).not.toContain("Old version of a fact")
|
||||
expect(result).toContain("1 memory across 1 document")
|
||||
})
|
||||
|
||||
it("marks documents whose extraction has not produced memories yet", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Still processing",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain(
|
||||
'"Still processing" (text, 2026-06-12) — no extracted memories yet',
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back to (untitled) for documents without a title", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: null,
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [makeEntry("Some fact")],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain('"(untitled)" (text, 2026-06-12)')
|
||||
})
|
||||
|
||||
it("flattens multi-line memories and truncates oversized ones", () => {
|
||||
const longMemory = `start ${"x".repeat(600)}`
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Big",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [
|
||||
makeEntry("line one\nline two\ttabbed"),
|
||||
makeEntry(longMemory),
|
||||
],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("- line one line two tabbed")
|
||||
expect(result).toContain("… [truncated]")
|
||||
const truncatedLine = result
|
||||
.split("\n")
|
||||
.find((line) => line.includes("[truncated]"))
|
||||
expect(truncatedLine).toBeDefined()
|
||||
expect((truncatedLine as string).length).toBeLessThan(600)
|
||||
})
|
||||
|
||||
it("points at the next page when more documents exist", () => {
|
||||
const result = formatMemoriesList(
|
||||
makeResponse({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
title: "Page one doc",
|
||||
type: "text",
|
||||
createdAt: "2026-06-12T08:00:00Z",
|
||||
updatedAt: "2026-06-12T08:00:00Z",
|
||||
memoryEntries: [makeEntry("A fact")],
|
||||
},
|
||||
],
|
||||
pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toContain("page 1 of 3, 3 documents total")
|
||||
expect(result).toContain("More available — call listMemories with page: 2.")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,56 @@
|
|||
import type { DocumentsApiResponse } from "./client"
|
||||
|
||||
// Listing must stay lightweight: memory entries are extracted facts (short
|
||||
// strings), never raw document content, so responses fit comfortably in
|
||||
// client output limits even at the maximum page size.
|
||||
const MAX_LIST_MEMORY_CHARS = 500
|
||||
|
||||
export function formatMemoriesList(response: DocumentsApiResponse): string {
|
||||
const { documents, pagination } = response
|
||||
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
|
||||
|
||||
if (documents.length === 0) {
|
||||
return pagination.currentPage > 1
|
||||
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
|
||||
: "No memories stored yet."
|
||||
}
|
||||
|
||||
let memoryCount = 0
|
||||
const blocks = documents.map((doc) => {
|
||||
const activeEntries = doc.memoryEntries.filter(
|
||||
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
|
||||
)
|
||||
const title = doc.title?.trim() || "(untitled)"
|
||||
const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})`
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
return `${header} — no extracted memories yet`
|
||||
}
|
||||
|
||||
memoryCount += activeEntries.length
|
||||
const lines = activeEntries.map((entry) => {
|
||||
const text = entry.memory.replace(/\s+/g, " ").trim()
|
||||
return `- ${
|
||||
text.length > MAX_LIST_MEMORY_CHARS
|
||||
? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} … [truncated]`
|
||||
: text
|
||||
}`
|
||||
})
|
||||
return [header, ...lines].join("\n")
|
||||
})
|
||||
|
||||
const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.`
|
||||
|
||||
const parts = [header, "", blocks.join("\n\n")]
|
||||
if (pagination.currentPage < pagination.totalPages) {
|
||||
parts.push(
|
||||
"",
|
||||
`More available — call listMemories with page: ${pagination.currentPage + 1}.`,
|
||||
)
|
||||
}
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
export function formatMemories(
|
||||
response: { results?: Array<Record<string, unknown>>; total?: number },
|
||||
opts: {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
RESOURCE_MIME_TYPE,
|
||||
} from "@modelcontextprotocol/ext-apps/server"
|
||||
import { SupermemoryClient } from "./client"
|
||||
import { formatMemories } from "./format"
|
||||
import { formatMemories, formatMemoriesList } from "./format"
|
||||
import { initPosthog, posthog } from "./posthog"
|
||||
import { z } from "zod"
|
||||
import mcpAppHtml from "../dist/mcp-app.html"
|
||||
|
|
@ -29,6 +29,20 @@ const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
|
|||
|
||||
const MAX_RECALL_CHARS = 200000
|
||||
|
||||
const READ_ONLY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
|
||||
const MEMORY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
|
||||
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
||||
private clientInfo: { name: string; version?: string } | null = null
|
||||
private cachedContainerTags: string[] = []
|
||||
|
|
@ -92,6 +106,27 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
...(hasRootContainerTag ? {} : containerTagField),
|
||||
})
|
||||
|
||||
const listMemoriesSchema = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe("Page number (1-based)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.default(10)
|
||||
.describe(
|
||||
"Documents per page; each document groups its extracted memories (default 10, max 50)",
|
||||
),
|
||||
...(hasRootContainerTag ? {} : containerTagField),
|
||||
})
|
||||
|
||||
const contextPromptSchema = z.object({
|
||||
includeRecent: z
|
||||
.boolean()
|
||||
|
|
@ -104,6 +139,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
type ContextPromptArgs = z.infer<typeof contextPromptSchema>
|
||||
type MemoryArgs = z.infer<typeof memorySchema>
|
||||
type RecallArgs = z.infer<typeof recallSchema>
|
||||
type ListMemoriesArgs = z.infer<typeof listMemoriesSchema>
|
||||
|
||||
// Register memory tool
|
||||
this.server.registerTool(
|
||||
|
|
@ -112,6 +148,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
description:
|
||||
"DO NOT USE ANY OTHER MEMORY TOOL ONLY USE THIS ONE. Save or forget information about the user. Use 'save' when user shares preferences, facts, or asks to remember something. Use 'forget' when information is outdated or user requests removal.",
|
||||
inputSchema: memorySchema,
|
||||
annotations: MEMORY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
(args: MemoryArgs) => this.handleMemory(args),
|
||||
|
|
@ -124,11 +161,25 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
description:
|
||||
"DO NOT USE ANY OTHER RECALL TOOL ONLY USE THIS ONE. Search the user's memories. Returns relevant memories plus their profile summary.",
|
||||
inputSchema: recallSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
(args: RecallArgs) => this.handleRecall(args),
|
||||
)
|
||||
|
||||
// Register listMemories tool
|
||||
this.server.registerTool(
|
||||
"listMemories",
|
||||
{
|
||||
description:
|
||||
"Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file — e.g. before forgetting stale memories or to power a 'list everything' view. For finding memories relevant to a topic, use 'recall' instead.",
|
||||
inputSchema: listMemoriesSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
(args: ListMemoriesArgs) => this.handleListMemories(args),
|
||||
)
|
||||
|
||||
// Register profile resource
|
||||
this.server.registerResource(
|
||||
"User Profile",
|
||||
|
|
@ -204,6 +255,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
"Force refresh from the server (default: false; uses cache with TTL)",
|
||||
),
|
||||
}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async (args: { refresh?: boolean }) => {
|
||||
|
|
@ -258,6 +310,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
{
|
||||
description: "Get the current logged-in user's information",
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
async () => {
|
||||
|
|
@ -308,6 +361,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
description:
|
||||
"Visualize the user's memory graph as an interactive force-directed graph showing documents, memories, and their relationships.",
|
||||
inputSchema: memoryGraphSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: { ui: { resourceUri: memoryGraphResourceUri } },
|
||||
},
|
||||
// @ts-expect-error - zod type inference issue with MCP SDK
|
||||
|
|
@ -371,6 +425,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(10),
|
||||
}),
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: memoryGraphResourceUri,
|
||||
|
|
@ -726,6 +781,46 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleListMemories(args: {
|
||||
page?: number
|
||||
limit?: number
|
||||
containerTag?: string
|
||||
}) {
|
||||
const { page = 1, limit = 10, containerTag } = args
|
||||
const effectiveContainerTag = containerTag || this.props?.containerTag
|
||||
|
||||
try {
|
||||
const client = this.getClient(effectiveContainerTag)
|
||||
const result = await client.getDocuments(
|
||||
effectiveContainerTag ? [effectiveContainerTag] : undefined,
|
||||
page,
|
||||
limit,
|
||||
)
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: formatMemoriesList(result),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
console.error("List memories operation failed:", error)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error listing memories: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getClientInfo(): Promise<
|
||||
{ name: string; version?: string } | undefined
|
||||
> {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["e2e/**/*.test.ts"],
|
||||
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
|
||||
testTimeout: 90_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
|
|
|
|||
137
apps/web/app/(app)/brain/page.tsx
Normal file
137
apps/web/app/(app)/brain/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
detectModeFromEmail,
|
||||
generateOrgSlug,
|
||||
workspaceDomainFromEmail,
|
||||
workspaceNameFromDomain,
|
||||
workspaceNameFromEmail,
|
||||
type BrainMetadata,
|
||||
} from "@/components/onboarding-brain/types"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
// No forms: sign up → org auto-created → Slack install.
|
||||
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
|
||||
export default function BrainEntryPage() {
|
||||
const router = useRouter()
|
||||
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||
useAuth()
|
||||
const { email = null } = user ?? {}
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const startedRef = useRef(false)
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (organizations && organizations.length > 0) {
|
||||
const active =
|
||||
org ?? organizations.find((o) => o.slug) ?? organizations[0]
|
||||
if (!org && active?.slug) await setActiveOrg(active.slug)
|
||||
const status = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
headers: { "X-App-Source": "nova" },
|
||||
})
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.catch(() => null)
|
||||
if (status?.connected) {
|
||||
router.replace("/")
|
||||
return
|
||||
}
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
return
|
||||
}
|
||||
|
||||
// Personal email → shell org; the Slack workspace resolves identity later.
|
||||
const domain =
|
||||
detectModeFromEmail(email) === "team"
|
||||
? workspaceDomainFromEmail(email)
|
||||
: null
|
||||
const name =
|
||||
(domain
|
||||
? workspaceNameFromDomain(domain)
|
||||
: workspaceNameFromEmail(email)) || "Company Brain"
|
||||
const metadata: BrainMetadata & { signupSource: string } = {
|
||||
signupSource: "consumer",
|
||||
brainOnboardingVersion: "v1",
|
||||
brainMode: "team",
|
||||
brainWorkspaceName: name,
|
||||
brainWorkspaceDomain: domain,
|
||||
// Always the shared Team Brain; the CB UI never selects a slug space.
|
||||
brainContainerTag: SHARED_TEAM_BRAIN_TAG,
|
||||
}
|
||||
const result = await authClient.organization.create({
|
||||
name,
|
||||
slug: generateOrgSlug(name),
|
||||
metadata,
|
||||
})
|
||||
if (result.error || !result.data?.slug) {
|
||||
throw new Error(result.error?.message || "Could not create workspace.")
|
||||
}
|
||||
await setActiveOrg(result.data.slug)
|
||||
await refetchOrganizations()
|
||||
analytics.onboardingWorkspaceCreated({
|
||||
mode: "team",
|
||||
has_about: false,
|
||||
has_domain: Boolean(domain),
|
||||
})
|
||||
window.location.href = `${BACKEND}/brain/slack/oauth/install`
|
||||
}, [email, org, organizations, setActiveOrg, refetchOrganizations, router])
|
||||
|
||||
// Sole caller of run(): the guard is only released on failure, so a dep change
|
||||
// mid-flight can't kick off a second org creation.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry
|
||||
useEffect(() => {
|
||||
if (!user || organizations === null || startedRef.current) return
|
||||
startedRef.current = true
|
||||
run().catch((e) => {
|
||||
startedRef.current = false
|
||||
console.error("Brain entry failed:", e)
|
||||
setError(e instanceof Error ? e.message : "Something went wrong.")
|
||||
})
|
||||
}, [user, organizations, run, attempt])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-dvh flex-col items-center justify-center gap-4 bg-[#05080D] px-6 text-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
Couldn't set up your Company Brain
|
||||
</p>
|
||||
<p className="max-w-sm text-[13px] text-[#8A94A6]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setAttempt((a) => a + 1)
|
||||
}}
|
||||
className="rounded-full bg-white px-5 py-2 text-[13px] font-semibold text-[#1D1C1D] hover:bg-white/95"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
|
||||
<p className="text-[14px] font-medium text-[#8A94A6]">
|
||||
Setting up your Company Brain…
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -86,8 +86,6 @@ export default function BrainOnboardingPage() {
|
|||
() => workspaceDomainFromEmail(user?.email),
|
||||
[user?.email],
|
||||
)
|
||||
|
||||
// Team (Company Brain) onboarding is gated behind a private-beta flag.
|
||||
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
|
||||
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
||||
const [about, setAbout] = useState<AboutValues>({
|
||||
|
|
@ -339,8 +337,6 @@ export default function BrainOnboardingPage() {
|
|||
setCreatingOrg(false)
|
||||
}
|
||||
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
||||
|
||||
// Company Brain (team) onboarding is a single research surface, no stepper.
|
||||
const isCompanyBrain = allowTeam && mode === "team"
|
||||
|
||||
const handleBrainConfirm = useCallback(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { motion } from "motion/react"
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { getBackendUrl, resolveAuthRedirectUrl } from "@/lib/url-helpers"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
|
||||
|
|
@ -276,7 +276,7 @@ export default function LoginPage() {
|
|||
const token = formData.get("token") as string
|
||||
const callbackURL = getCallbackURL()
|
||||
router.push(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
`${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,29 +13,137 @@ function isValidUrl(urlString: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function isPrivateIPv4Octets(a: number, b: number): boolean {
|
||||
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
|
||||
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
|
||||
if (a === 0) return true
|
||||
if (a === 10) return true
|
||||
if (a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 100 && b >= 64 && b <= 127) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse an IPv6 hostname into its eight 16-bit groups. The WHATWG URL parser
|
||||
// already canonicalizes literals, but it leaves the brackets on (e.g. "[::1]")
|
||||
// and performs no SSRF filtering. Handles "::" compression and embedded IPv4
|
||||
// suffixes (e.g. ::ffff:1.2.3.4). Returns null when not an IPv6 literal.
|
||||
function parseIPv6Groups(input: string): number[] | null {
|
||||
let host = input
|
||||
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1)
|
||||
const zone = host.indexOf("%") // strip scope/zone id, e.g. fe80::1%eth0
|
||||
if (zone !== -1) host = host.slice(0, zone)
|
||||
if (!host.includes(":")) return null
|
||||
|
||||
// Convert an embedded IPv4 tail (e.g. ::ffff:1.2.3.4) into two hex groups.
|
||||
const dot = host.indexOf(".")
|
||||
if (dot !== -1) {
|
||||
const colon = host.lastIndexOf(":", dot)
|
||||
if (colon === -1) return null
|
||||
const octets = host.slice(colon + 1).split(".")
|
||||
if (octets.length !== 4) return null
|
||||
const bytes: number[] = []
|
||||
for (const octet of octets) {
|
||||
if (!/^\d{1,3}$/.test(octet)) return null
|
||||
const n = Number(octet)
|
||||
if (n > 255) return null
|
||||
bytes.push(n)
|
||||
}
|
||||
const hi = (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)).toString(16)
|
||||
const lo = (((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0)).toString(16)
|
||||
host = `${host.slice(0, colon + 1)}${hi}:${lo}`
|
||||
}
|
||||
|
||||
const parseSide = (side: string): number[] | null => {
|
||||
if (side === "") return []
|
||||
const groups: number[] = []
|
||||
for (const group of side.split(":")) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
|
||||
groups.push(Number.parseInt(group, 16))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const halves = host.split("::")
|
||||
if (halves.length > 2) return null
|
||||
|
||||
if (halves.length === 2) {
|
||||
const head = parseSide(halves[0] ?? "")
|
||||
const tail = parseSide(halves[1] ?? "")
|
||||
if (!head || !tail) return null
|
||||
const missing = 8 - head.length - tail.length
|
||||
if (missing < 1) return null
|
||||
return [...head, ...new Array<number>(missing).fill(0), ...tail]
|
||||
}
|
||||
|
||||
const all = parseSide(host)
|
||||
if (all?.length !== 8) return null
|
||||
return all
|
||||
}
|
||||
|
||||
function isPrivateIPv6(input: string): boolean {
|
||||
const g = parseIPv6Groups(input)
|
||||
if (!g) return false
|
||||
|
||||
// Unspecified (::) and loopback (::1)
|
||||
if (g.every((x) => x === 0)) return true
|
||||
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true
|
||||
|
||||
// Link-local fe80::/10
|
||||
if (((g[0] ?? 0) & 0xffc0) === 0xfe80) return true
|
||||
|
||||
// Unique local address fc00::/7 (fc00–fdff)
|
||||
if ((((g[0] ?? 0) >> 8) & 0xfe) === 0xfc) return true
|
||||
|
||||
// IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d):
|
||||
// re-check the embedded IPv4 against the private ranges so loopback/metadata
|
||||
// can't be reached via an IPv6 wrapper.
|
||||
const firstFiveZero = g.slice(0, 5).every((x) => x === 0)
|
||||
const ipv4Mapped = firstFiveZero && g[5] === 0xffff
|
||||
const ipv4Compatible =
|
||||
firstFiveZero && g[5] === 0 && (g[6] !== 0 || g[7] !== 0)
|
||||
if (ipv4Mapped || ipv4Compatible) {
|
||||
const a = ((g[6] ?? 0) >> 8) & 0xff
|
||||
const b = (g[6] ?? 0) & 0xff
|
||||
return isPrivateIPv4Octets(a, b)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NOTE: This blocks private/loopback/metadata IP *literals* only. It cannot stop
|
||||
// DNS rebinding (a public hostname that resolves to a private address), which
|
||||
// would require resolving + pinning the address before connecting — not available
|
||||
// in the edge fetch runtime here.
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
const lowerHost = hostname.toLowerCase()
|
||||
|
||||
if (
|
||||
lowerHost === "localhost" ||
|
||||
lowerHost === "127.0.0.1" ||
|
||||
lowerHost === "0.0.0.0" ||
|
||||
lowerHost === "::1" ||
|
||||
lowerHost === "::" ||
|
||||
lowerHost.startsWith("127.") ||
|
||||
lowerHost.startsWith("0.0.0.0")
|
||||
) {
|
||||
// Hostname-based loopback (RFC 6761 reserves localhost and *.localhost)
|
||||
if (lowerHost === "localhost" || lowerHost.endsWith(".localhost")) {
|
||||
return true
|
||||
}
|
||||
|
||||
const privateIpPatterns = [
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./, // Link-local / Metadata service
|
||||
]
|
||||
// IPv6 literals arrive bracketed (e.g. "[::1]"), so the previous
|
||||
// string-equality checks never matched them.
|
||||
if (lowerHost.includes(":")) {
|
||||
return isPrivateIPv6(lowerHost)
|
||||
}
|
||||
|
||||
return privateIpPatterns.some((pattern) => pattern.test(hostname))
|
||||
// IPv4. The WHATWG URL parser canonicalizes alternate encodings
|
||||
// (decimal/hex/octal, e.g. http://2130706433 -> 127.0.0.1) to dotted-quad,
|
||||
// so matching the dotted form here also blocks those encodings.
|
||||
const octets = lowerHost.split(".")
|
||||
if (
|
||||
octets.length === 4 &&
|
||||
octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
|
||||
) {
|
||||
const [a, b] = octets.map(Number) as [number, number, number, number]
|
||||
return isPrivateIPv4Octets(a, b)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// File extensions that are not HTML and can't be scraped for OG data
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
|||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
import { ConfigureView } from "@/components/configure-view"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
|
|
@ -28,6 +29,7 @@ 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 { SlackHandoff } from "@/components/onboarding-brain/slack-handoff"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { DocumentModal } from "@/components/document-modal"
|
||||
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
|
||||
|
|
@ -67,6 +69,7 @@ import {
|
|||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { getToolDocumentSpace } from "@/lib/plugin-space"
|
||||
import { getBackendUrl } from "@/lib/url-helpers"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -134,17 +137,25 @@ export function AppExperience() {
|
|||
const { viewMode, setViewMode } = useViewMode()
|
||||
useLegacyViewRedirect()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const backendUrl = getBackendUrl()
|
||||
|
||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
||||
// ?slack=connected: CB orgs get the handoff takeover, everyone else a toast.
|
||||
const [slackHandoff, setSlackHandoff] = useState<{
|
||||
team: string | null
|
||||
} | null>(null)
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (sp.get("slack") !== "connected") return
|
||||
const team = sp.get("team")
|
||||
toast.success(
|
||||
team
|
||||
? `Supermemory added to ${team} on Slack`
|
||||
: "Supermemory added to your Slack",
|
||||
)
|
||||
if (isCompanyBrain) {
|
||||
setSlackHandoff({ team })
|
||||
} else {
|
||||
toast.success(
|
||||
team
|
||||
? `Supermemory added to ${team} on Slack`
|
||||
: "Supermemory added to your Slack",
|
||||
)
|
||||
}
|
||||
sp.delete("slack")
|
||||
sp.delete("team")
|
||||
const qs = sp.toString()
|
||||
|
|
@ -153,7 +164,7 @@ export function AppExperience() {
|
|||
"",
|
||||
window.location.pathname + (qs ? `?${qs}` : ""),
|
||||
)
|
||||
}, [])
|
||||
}, [isCompanyBrain])
|
||||
const queryClient = useQueryClient()
|
||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||
|
||||
|
|
@ -331,7 +342,7 @@ export function AppExperience() {
|
|||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const spaceId = selectedProject || "sm_project_default"
|
||||
const forceRefresh = highlightsForceAt > 0
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
|
|
@ -345,22 +356,19 @@ export function AppExperience() {
|
|||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
spaceId,
|
||||
highlightsCount: 3,
|
||||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
forceRefresh,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const response = await fetch(`${backendUrl}/v3/space-highlights`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
spaceId,
|
||||
highlightsCount: 3,
|
||||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
forceRefresh,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch space highlights")
|
||||
|
|
@ -404,10 +412,9 @@ export function AppExperience() {
|
|||
if (stored) return JSON.parse(stored) as MemoryOfDay
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
const response = await fetch(`${backendUrl}/v3/memory-of-day`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as MemoryOfDay | null
|
||||
if (data) {
|
||||
|
|
@ -617,6 +624,12 @@ export function AppExperience() {
|
|||
return (
|
||||
<HotkeysProvider>
|
||||
<OnboardingConfetti />
|
||||
{slackHandoff && (
|
||||
<SlackHandoff
|
||||
teamName={slackHandoff.team}
|
||||
onDismiss={() => setSlackHandoff(null)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
|
|
@ -700,6 +713,10 @@ export function AppExperience() {
|
|||
onOpenDocument={handleOpenDocument}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "configure" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! md:p-6">
|
||||
<ConfigureView />
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ function StatsRow({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
className="hidden items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] sm:inline-flex"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
Invite
|
||||
|
|
@ -235,31 +235,64 @@ function StatsRow({
|
|||
]
|
||||
return (
|
||||
<section
|
||||
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
|
||||
className="grid grid-cols-4 divide-x divide-white/[0.04] overflow-hidden rounded-[16px] bg-[#1B1F24]"
|
||||
style={cardStyle}
|
||||
>
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
{tiles.map((t, index) => (
|
||||
<div
|
||||
key={t.label}
|
||||
className={cn(
|
||||
"relative min-w-0 px-3 py-3 sm:px-5 sm:py-4",
|
||||
index === 2 && canInvite && "pr-8 sm:pr-5",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-[8px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#737373] sm:text-[10px] sm:tracking-[0.12em]">
|
||||
<MobileStatLabel label={t.label} />
|
||||
</p>
|
||||
{t.action}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
|
||||
"mt-1 text-[17px] font-semibold leading-none tabular-nums text-[#fafafa] sm:mt-1.5 sm:text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{t.value}
|
||||
</p>
|
||||
{index === 2 && canInvite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInvite}
|
||||
aria-label="Invite teammates"
|
||||
title="Invite teammates"
|
||||
className="absolute right-1.5 bottom-1.5 inline-flex size-6 items-center justify-center rounded-md bg-white/[0.03] text-[#737373] transition-colors hover:bg-white/[0.07] hover:text-[#fafafa] sm:hidden"
|
||||
>
|
||||
<UserPlus className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStatLabel({ label }: { label: string }) {
|
||||
const mobile =
|
||||
label === "Connected sources"
|
||||
? "Sources"
|
||||
: label === "Active members"
|
||||
? "Members"
|
||||
: label
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sm:hidden">{mobile}</span>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentMemories({
|
||||
docs,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { ArrowRight, Loader2 } from "lucide-react"
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
||||
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
|
||||
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
|
||||
// Preferred ordering for the dashboard; only unconnected apps are surfaced.
|
||||
const FEATURED_SLUGS: readonly string[] = ["linear", "granola", "sentry"]
|
||||
// Example prompts on the right card — can include apps not shown on the left.
|
||||
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
|
||||
|
||||
|
|
@ -155,76 +155,91 @@ export function ConnectionsBoard() {
|
|||
}
|
||||
}
|
||||
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const apps = catalog ?? []
|
||||
const loading = catalog === null
|
||||
const featured = FEATURED_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const unconnected = apps.filter((a) => !isConnected(a.slug))
|
||||
const featured = [
|
||||
...FEATURED_SLUGS.map((slug) =>
|
||||
unconnected.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a)),
|
||||
...unconnected.filter((a) => !FEATURED_SLUGS.includes(a.slug)),
|
||||
].slice(0, 3)
|
||||
const overflow = unconnected.filter((a) => !featured.includes(a))
|
||||
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const remainingCount = Math.max(apps.length - featured.length, 0)
|
||||
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
|
||||
const showBoard = loading || unconnected.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{slack && !slack.connected && <SlackBanner />}
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-5">
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={i < featured.length - 1 || remainingCount > 0}
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<MoreTile
|
||||
count={remainingCount}
|
||||
onClick={() => openSettings("company-brain")}
|
||||
/>
|
||||
{showBoard ? (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(
|
||||
entry.slug,
|
||||
entry.name,
|
||||
"size-5",
|
||||
)}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={
|
||||
i < featured.length - 1 || overflow.length > 0
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<MoreTile
|
||||
count={overflow.length}
|
||||
names={overflow.slice(0, 3).map((a) => a.name)}
|
||||
onClick={() => void setViewMode("configure")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<AgentPreview
|
||||
apps={previewApps}
|
||||
isConnected={isConnected}
|
||||
connectedCount={connectedCount}
|
||||
wide={!showBoard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -235,10 +250,12 @@ function AgentPreview({
|
|||
apps,
|
||||
isConnected,
|
||||
connectedCount,
|
||||
wide = false,
|
||||
}: {
|
||||
apps: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
connectedCount: number
|
||||
wide?: boolean
|
||||
}) {
|
||||
const prompts = apps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
|
|
@ -252,7 +269,10 @@ function AgentPreview({
|
|||
|
||||
return (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
|
||||
className={cn(
|
||||
"relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5",
|
||||
wide ? "lg:col-span-5" : "lg:col-span-2",
|
||||
)}
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -359,7 +379,15 @@ function AppTile({
|
|||
)
|
||||
}
|
||||
|
||||
function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
function MoreTile({
|
||||
count,
|
||||
names,
|
||||
onClick,
|
||||
}: {
|
||||
count: number
|
||||
names: string[]
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -380,7 +408,8 @@ function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
|||
{count} more {count === 1 ? "app" : "apps"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
Notion, PostHog, Plain and more
|
||||
{names.join(", ")}
|
||||
{count > names.length ? " and more" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[88px] shrink-0 justify-end">
|
||||
|
|
@ -411,7 +440,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
|||
function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div
|
||||
|
|
@ -422,37 +451,45 @@ function SlackBanner() {
|
|||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="flex items-center justify-between gap-3 sm:gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3 sm:gap-3.5">
|
||||
<div
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F] sm:size-12"
|
||||
style={tileStyle}
|
||||
>
|
||||
<SlackMark className="size-7" />
|
||||
<SlackMark className="size-6 sm:size-7" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
"truncate text-[15px] font-semibold leading-tight text-[#fafafa] sm:text-[16px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Company Brain in Slack
|
||||
<span className="sm:hidden">Slack agent</span>
|
||||
<span className="hidden sm:inline">Company Brain in Slack</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] font-medium leading-[1.5] text-[#737373]">
|
||||
Install Supermemory so your team can{" "}
|
||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
||||
channel.
|
||||
<p className="mt-1 truncate text-[12px] font-medium leading-[1.45] text-[#737373] sm:mt-0.5 sm:text-[13px] sm:leading-[1.5]">
|
||||
<span className="sm:hidden">
|
||||
Ask <span className="text-[#A1A1AA]">@supermemory</span> from
|
||||
any channel.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Install Supermemory so your team can{" "}
|
||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
||||
channel.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-white px-4 py-2.5 text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:self-auto"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
|
||||
>
|
||||
<SlackMark className="size-[18px]" />
|
||||
Add to Slack
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1111,7 +1111,7 @@ export function ChatSidebar({
|
|||
const params = new URLSearchParams({ projectId: chatProject })
|
||||
if (historyScope === "all") params.set("scope", "all")
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`,
|
||||
`${chatApiBase}/chat/threads?${params.toString()}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
|
|
@ -1123,7 +1123,7 @@ export function ChatSidebar({
|
|||
} finally {
|
||||
setIsLoadingThreads(false)
|
||||
}
|
||||
}, [chatProject, historyScope])
|
||||
}, [chatApiBase, chatProject, historyScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHistoryOpen) return
|
||||
|
|
@ -1134,10 +1134,9 @@ export function ChatSidebar({
|
|||
const loadThread = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
const response = await fetch(`${chatApiBase}/chat/threads/${id}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const uiMessages = data.messages.map(
|
||||
|
|
@ -1179,7 +1178,7 @@ export function ChatSidebar({
|
|||
console.error("Failed to load thread:", error)
|
||||
}
|
||||
},
|
||||
[setThreadId],
|
||||
[chatApiBase, setThreadId],
|
||||
)
|
||||
|
||||
// Auto-restore thread from URL on mount (e.g. reload or direct link)
|
||||
|
|
@ -1197,7 +1196,7 @@ export function ChatSidebar({
|
|||
async (threadId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
|
||||
`${chatApiBase}/chat/threads/${threadId}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (response.ok) {
|
||||
|
|
@ -1213,7 +1212,7 @@ export function ChatSidebar({
|
|||
setConfirmingDeleteId(null)
|
||||
}
|
||||
},
|
||||
[currentChatId, handleNewChat],
|
||||
[chatApiBase, currentChatId, handleNewChat],
|
||||
)
|
||||
|
||||
const formatRelativeTime = (isoString: string): string => {
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import {
|
|||
ExternalLink,
|
||||
Home,
|
||||
LifeBuoy,
|
||||
Link2,
|
||||
LayoutGrid,
|
||||
MenuIcon,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Settings2,
|
||||
UserPlus,
|
||||
ChevronRight,
|
||||
Sun,
|
||||
|
|
@ -35,7 +35,7 @@ import { DomainLogo } from "@/components/onboarding-brain/step-about"
|
|||
import { FeedbackModal } from "@/components/feedback-modal"
|
||||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
|
|
@ -121,7 +121,6 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
feedbackParam,
|
||||
)
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
const [settingsTab] = useQueryState("settings")
|
||||
const { data: slackStatus } = useSlackStatus()
|
||||
|
||||
const planByOrgId = new Map(
|
||||
|
|
@ -140,10 +139,10 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
?.role?.toLowerCase()
|
||||
const canInvite = memberRole === "owner" || memberRole === "admin"
|
||||
|
||||
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
|
||||
const isOverview = viewMode === "dashboard"
|
||||
const isGraph = viewMode === "graph"
|
||||
const isMemories = viewMode === "list"
|
||||
const isConnections = settingsTab === "company-brain"
|
||||
const isConfigure = viewMode === "configure"
|
||||
const slackConnected = slackStatus?.connected ?? false
|
||||
|
||||
const selectOrg = useCallback(
|
||||
|
|
@ -166,9 +165,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
void setViewMode("list")
|
||||
}, [setViewMode])
|
||||
|
||||
const goConnections = useCallback(() => {
|
||||
openSettings("company-brain")
|
||||
}, [openSettings])
|
||||
const goConfigure = useCallback(() => {
|
||||
void setViewMode("configure")
|
||||
}, [setViewMode])
|
||||
|
||||
const goIntegrations = useCallback(() => {
|
||||
void setViewMode("integrations")
|
||||
|
|
@ -184,16 +183,16 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
}, [setFeedbackOpen])
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1 px-2 py-2 md:grid md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 flex-1 shrink items-center justify-start gap-1.5 md:justify-self-start md:gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex max-w-[min(52vw,240px)] shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
|
||||
className="relative flex min-w-0 max-w-[31vw] shrink cursor-pointer items-center rounded-lg px-1 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none min-[380px]:max-w-[9rem] sm:max-w-[min(52vw,240px)] md:-ml-2 md:max-w-[min(52vw,240px)] md:shrink-0 md:px-1.5 before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] md:before:-inset-x-2 md:before:-inset-y-2.5"
|
||||
>
|
||||
<div
|
||||
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] sm:size-8"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
|
|
@ -205,7 +204,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
<Building2 className="size-4 text-[#737373]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
|
||||
<div className="ml-1.5 min-w-0 flex flex-col items-start justify-center max-[340px]:hidden sm:ml-2">
|
||||
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
Company Brain
|
||||
</p>
|
||||
|
|
@ -264,9 +263,9 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
Home
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
|
||||
<Link2 className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
<DropdownMenuItem onClick={goConfigure} className={menuItemClass}>
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
|
|
@ -318,7 +317,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
|
||||
<div className="z-10! flex min-w-0 max-w-full items-center justify-center gap-1.5 overflow-hidden px-1 md:justify-self-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -364,24 +363,24 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isConnections}
|
||||
onClick={goConnections}
|
||||
className={tabClass(isConnections)}
|
||||
aria-selected={isConfigure}
|
||||
onClick={goConfigure}
|
||||
className={tabClass(isConfigure)}
|
||||
>
|
||||
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
|
||||
Connections
|
||||
<Settings2 className="size-3.5 shrink-0 sm:size-4" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
<SlackNavButton
|
||||
connected={slackConnected}
|
||||
teamName={slackStatus?.teamName ?? null}
|
||||
active={isConnections && slackConnected}
|
||||
onManage={goConnections}
|
||||
active={isConfigure && slackConnected}
|
||||
onManage={goConfigure}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="z-10! flex shrink-0 items-center gap-1.5">
|
||||
<div className="z-10! flex min-w-0 shrink-0 items-center gap-1.5 md:justify-self-end">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
|
|
@ -389,12 +388,14 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
onValueChange={setSelectedProjects}
|
||||
enableDelete={false}
|
||||
compact
|
||||
triggerClassName="max-w-[34vw] shrink min-[380px]:max-w-[9rem]"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
aria-label="Open navigation menu"
|
||||
className="size-9! min-h-9 min-w-9 rounded-full px-0! text-base"
|
||||
>
|
||||
<MenuIcon className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -429,11 +430,11 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
onClick={goConfigure}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<IntegrationsIcon className="size-4 text-[#737373]" />
|
||||
Connections
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={goIntegrations}
|
||||
|
|
@ -444,7 +445,7 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
{slackConnected ? (
|
||||
<DropdownMenuItem
|
||||
onClick={goConnections}
|
||||
onClick={goConfigure}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
|
|
|
|||
130
apps/web/components/configure-view.tsx
Normal file
130
apps/web/components/configure-view.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Blocks, CalendarClock, Cpu } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import CompanyBrainModels from "@/components/settings/company-brain-models"
|
||||
import Proactiveness from "@/components/settings/proactiveness"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
type ConfigureSection = "company-brain" | "models" | "automations"
|
||||
|
||||
const SECTIONS: {
|
||||
id: ConfigureSection
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Blocks
|
||||
}[] = [
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Integrations",
|
||||
description:
|
||||
"Connect the tools your brain works with. Your account covers your own actions and reads; workspace accounts are a shared fallback.",
|
||||
icon: Blocks,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
description:
|
||||
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
label: "Automations",
|
||||
description:
|
||||
"Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
]
|
||||
|
||||
export function ConfigureView() {
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<ConfigureSection>("company-brain")
|
||||
const active = SECTIONS.find((section) => section.id === activeSection)
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
|
||||
)}
|
||||
>
|
||||
<section
|
||||
aria-label="Configure Company Brain"
|
||||
className="flex flex-1 flex-col rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-6"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-5 md:flex-row md:gap-8">
|
||||
<nav
|
||||
aria-label="Configure sections"
|
||||
className="scrollbar-none flex shrink-0 gap-1 overflow-x-auto md:w-52 md:flex-col md:overflow-x-visible"
|
||||
>
|
||||
<p className="hidden px-3 pb-1.5 font-semibold text-[11px] text-[#5B6675] uppercase tracking-[0.08em] md:block">
|
||||
Configure
|
||||
</p>
|
||||
{SECTIONS.map((section) => {
|
||||
const isActive = section.id === activeSection
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:bg-white/[0.04] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isActive ? "text-[#FAFAFA]" : "text-[#737B87]",
|
||||
)}
|
||||
/>
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="mb-5">
|
||||
<h2
|
||||
id="configure-section-title"
|
||||
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
|
||||
>
|
||||
{active.label}
|
||||
</h2>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
|
||||
{active.description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ErrorBoundary
|
||||
key={activeSection}
|
||||
fallback={
|
||||
<p className="py-6 text-center text-[13px] text-[#8B929E]">
|
||||
Something went wrong loading this section.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeSection === "company-brain" ? (
|
||||
<CompanyBrainConnections />
|
||||
) : activeSection === "models" ? (
|
||||
<CompanyBrainModels showHeading={false} />
|
||||
) : (
|
||||
<Proactiveness />
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "@ui/assets/icons"
|
||||
import { Globe, FileText, FileCode, Image } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
function MCPIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
|
|
@ -206,7 +207,7 @@ export function DocumentIcon({
|
|||
return <MCPIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
|
||||
if (isYouTubeUrl(url)) {
|
||||
return <YouTubeIcon className={iconClassName} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import dynamic from "next/dynamic"
|
||||
import { isTwitterUrl } from "@/lib/url-helpers"
|
||||
import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { ImagePreview } from "./image-preview"
|
||||
import { TweetContent } from "./tweet"
|
||||
import { NotionDoc } from "./notion-doc"
|
||||
|
|
@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
|
|||
if (document.type === "google_doc") return "google_doc"
|
||||
if (document.type === "google_sheet") return "google_sheet"
|
||||
if (document.type === "google_slide") return "google_slide"
|
||||
if (document.url?.includes("youtube.com")) return "youtube"
|
||||
if (isYouTubeUrl(document.url)) return "youtube"
|
||||
if (document.type === "webpage") return "webpage"
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
(pathname === "/" &&
|
||||
["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
|
||||
const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
|
||||
const isOnboarding = pathname.startsWith("/onboarding")
|
||||
// /brain is the Slack-first Company Brain entry: it creates the org itself.
|
||||
const isOnboarding =
|
||||
pathname.startsWith("/onboarding") ||
|
||||
pathname === "/brain" ||
|
||||
pathname.startsWith("/brain/")
|
||||
|
||||
useEffect(() => {
|
||||
if (isGuestPublicAppPage) return
|
||||
|
|
|
|||
|
|
@ -101,6 +101,34 @@ type OgData = {
|
|||
image?: string
|
||||
}
|
||||
|
||||
const EXTENSION_PLATFORM_LABELS: Record<string, string> = {
|
||||
chatgpt: "ChatGPT",
|
||||
claude: "Claude",
|
||||
gemini: "Gemini",
|
||||
t3: "T3 Chat",
|
||||
twitter: "X / Twitter",
|
||||
}
|
||||
|
||||
function getExtensionSourceLabel(
|
||||
document: DocumentWithMemories,
|
||||
): string | null {
|
||||
const metadata = document.metadata
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
|
||||
const label = metadata.sm_origin_platform_label
|
||||
if (typeof label === "string" && label.trim()) {
|
||||
return label.trim()
|
||||
}
|
||||
|
||||
const platform = metadata.sm_origin_platform
|
||||
if (typeof platform === "string" && platform.trim()) {
|
||||
const normalized = platform.trim().toLowerCase()
|
||||
return EXTENSION_PLATFORM_LABELS[normalized] || platform.trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const ogCache = new Map<string, OgData>()
|
||||
const ogInflight = new Map<string, Promise<OgData | null>>()
|
||||
const ogFailures = new Map<string, number>()
|
||||
|
|
@ -1229,6 +1257,7 @@ const DocumentCard = memo(
|
|||
pluginDocument?.kind === "claude-code-doc"
|
||||
? claudeCodeTokenBadge(document)
|
||||
: null
|
||||
const sourceLabel = getExtensionSourceLabel(document)
|
||||
const date = new Date(
|
||||
document.createdAt,
|
||||
).toLocaleDateString("en-US", {
|
||||
|
|
@ -1236,7 +1265,9 @@ const DocumentCard = memo(
|
|||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
return badge ? `${badge} · ${date}` : date
|
||||
return [sourceLabel, badge, date]
|
||||
.filter(Boolean)
|
||||
.join(" - ")
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,12 +43,21 @@ const BACKEND =
|
|||
type Phase = "confirm" | "research"
|
||||
|
||||
function normalizeDomain(input: string): string {
|
||||
return input
|
||||
const host = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^www\./, "")
|
||||
.replace(/\/.*$/, "")
|
||||
|
||||
// The confirmation card is often filled with a company name (for example,
|
||||
// "Zomato") even though research needs a hostname. Preserve explicit TLDs,
|
||||
// and make the common single-label case usable without a failed API round trip.
|
||||
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
|
||||
return `${host}.com`
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
export function CompanyBrainOnboarding({
|
||||
|
|
@ -67,6 +76,11 @@ export function CompanyBrainOnboarding({
|
|||
const queryClient = useQueryClient()
|
||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||
const researchDone = researchStatus === "done"
|
||||
// One-shot retry: re-kick research once on a terminal error.
|
||||
const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">(
|
||||
"idle",
|
||||
)
|
||||
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!clean || submitting) return
|
||||
|
|
@ -111,6 +125,48 @@ export function CompanyBrainOnboarding({
|
|||
return () => window.clearTimeout(timer)
|
||||
}, [phase, serverSchedulesResearch, clean, queryClient])
|
||||
|
||||
// "error" lingers a render after re-kicking, so only arm the second-error
|
||||
// branch once the re-run is observed running.
|
||||
useEffect(() => {
|
||||
if (phase !== "research" || !clean) return
|
||||
const stage = retryStage.current
|
||||
if (researchStatus === "error" && stage === "idle") {
|
||||
retryStage.current = "started"
|
||||
setRetryUi("retrying")
|
||||
void (async () => {
|
||||
// A failed restart never reaches queued/running, and polling is off on
|
||||
// error — without this the UI would say "retrying" forever.
|
||||
const ok = await fetch(`${BACKEND}/brain/research/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-App-Source": "nova",
|
||||
},
|
||||
body: JSON.stringify({ domain: clean }),
|
||||
})
|
||||
.then((res) => res.ok)
|
||||
.catch(() => false)
|
||||
if (!ok) {
|
||||
retryStage.current = "exhausted"
|
||||
setRetryUi("exhausted")
|
||||
return
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
|
||||
})()
|
||||
} else if (
|
||||
(researchStatus === "queued" || researchStatus === "running") &&
|
||||
stage === "started"
|
||||
) {
|
||||
retryStage.current = "rerunning"
|
||||
} else if (researchStatus === "error" && stage === "rerunning") {
|
||||
retryStage.current = "exhausted"
|
||||
setRetryUi("exhausted")
|
||||
} else if (researchStatus === "done") {
|
||||
setRetryUi(null)
|
||||
}
|
||||
}, [phase, clean, researchStatus, queryClient])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -173,6 +229,8 @@ export function CompanyBrainOnboarding({
|
|||
<DockedHeader
|
||||
domain={clean}
|
||||
done={researchDone}
|
||||
retrying={retryUi === "retrying"}
|
||||
exhausted={retryUi === "exhausted"}
|
||||
onContinue={onDone}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
@ -298,54 +356,76 @@ function ConfirmBody({
|
|||
function DockedHeader({
|
||||
domain,
|
||||
done,
|
||||
retrying,
|
||||
exhausted,
|
||||
onContinue,
|
||||
}: {
|
||||
domain: string
|
||||
done: boolean
|
||||
retrying: boolean
|
||||
exhausted: boolean
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const brandName = workspaceNameFromDomain(domain) || domain
|
||||
const showSpinner = !done && !exhausted
|
||||
const statusLabel = done
|
||||
? "Company Brain ready"
|
||||
: exhausted
|
||||
? "Couldn't finish — you can continue"
|
||||
: retrying
|
||||
? "Retrying research…"
|
||||
: "Building your Company Brain…"
|
||||
const statusColor = done
|
||||
? "text-[#5CD68A]"
|
||||
: exhausted
|
||||
? "text-[#E5A45A]"
|
||||
: "text-[#737373]"
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<DomainLogo domain={domain} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-[#fafafa]">
|
||||
{brandName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12px] font-medium",
|
||||
done ? "text-[#5CD68A]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? "Company Brain ready" : "Building your Company Brain…"}
|
||||
</span>
|
||||
{done ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-3">
|
||||
<span
|
||||
title={brandName}
|
||||
className="min-w-0 truncate text-[14px] font-semibold text-[#fafafa] md:flex-1"
|
||||
>
|
||||
{brandName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
statusColor,
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
|
||||
)}
|
||||
{showSpinner && (
|
||||
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Never gated on research; the admin can move on while it keeps working. */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className={cn(
|
||||
"ml-auto shrink-0 rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResearchTranscript() {
|
||||
const { status, events } = useResearchStatus()
|
||||
const running = status !== "done"
|
||||
const running = status !== "done" && status !== "error"
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
brainConnectorIcon,
|
||||
SlackMark,
|
||||
} from "@/components/brain-connector-icons"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useResearchStatus } from "@/hooks/use-research-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
||||
|
|
@ -83,7 +83,7 @@ export function ResearchActionRail({
|
|||
onStatsChange?: (stats: ParallelSetupStats) => void
|
||||
}) {
|
||||
const { org } = useAuth()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const router = useRouter()
|
||||
const { events } = useResearchStatus()
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
|
|
@ -448,7 +448,7 @@ export function ResearchActionRail({
|
|||
onConnect={connect}
|
||||
onBrowse={() => {
|
||||
pauseRotation()
|
||||
openSettings("company-brain")
|
||||
router.push("/?view=configure")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function BrainShell({ step, steps, children }: ShellProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
|
||||
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -46,20 +46,25 @@ export function BrainShell({ step, steps, children }: ShellProps) {
|
|||
}}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 py-4 md:px-10">
|
||||
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
|
||||
<div className="hidden justify-center md:flex">
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
</div>
|
||||
<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",
|
||||
"relative z-10 flex min-h-dvh flex-col items-center px-4 md:px-10",
|
||||
step === "sources"
|
||||
? "items-start overflow-y-auto"
|
||||
: "items-center overflow-hidden",
|
||||
? "justify-start overflow-y-auto pt-20 pb-10"
|
||||
: "justify-center overflow-y-auto py-20 md:overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 flex justify-center md:hidden">
|
||||
<StepIndicator step={step} visibleSteps={visibleSteps} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
|
|
@ -89,7 +94,7 @@ function StepIndicator({
|
|||
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]">
|
||||
<div className="flex min-w-[44px] flex-col items-center gap-1.5 md:min-w-[58px]">
|
||||
<StepDot done={isDone} current={isCurrent} />
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -105,7 +110,7 @@ function StepIndicator({
|
|||
</span>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
|
||||
<div className="mt-[5px] h-px min-w-[18px] flex-1 md:min-w-[28px]">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
|
|
|
|||
61
apps/web/components/onboarding-brain/slack-handoff.tsx
Normal file
61
apps/web/components/onboarding-brain/slack-handoff.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function SlackHandoff({
|
||||
teamName,
|
||||
onDismiss,
|
||||
}: {
|
||||
teamName: string | null
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[100] flex items-center justify-center bg-[#05080D]/90 backdrop-blur-sm px-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 26 }}
|
||||
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] border border-white/[0.06] p-8 text-center"
|
||||
>
|
||||
<div className="mx-auto mb-5 flex size-14 items-center justify-center rounded-[16px] bg-[#14161A] border border-[rgba(82,89,102,0.2)]">
|
||||
<SlackMark className="size-7" />
|
||||
</div>
|
||||
<h2 className="text-[20px] font-semibold text-[#FAFAFA]">
|
||||
{teamName
|
||||
? `Company Brain is live in ${teamName}`
|
||||
: "Company Brain is live in your Slack"}
|
||||
</h2>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[#8A94A6]">
|
||||
We sent you a DM to get started. Ask it anything about your company —
|
||||
it answers where your team already works.
|
||||
</p>
|
||||
<a
|
||||
href="slack://open"
|
||||
className={cn(
|
||||
"mt-6 inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-3 text-[14px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Open Slack
|
||||
<ArrowRight className="size-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="mt-3 w-full text-[12px] font-medium text-[#525D6E] transition-colors hover:text-[#8A94A6]"
|
||||
>
|
||||
Stay in the browser
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -474,9 +474,9 @@ export function StepSources({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-4">
|
||||
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
|
||||
<div className="mx-auto w-full max-w-[1400px] pb-28 md:pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-3 md:py-4">
|
||||
<div className="md:absolute md:inset-x-0 md:top-[46%] md:-translate-y-1/2">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -494,7 +494,7 @@ export function StepSources({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
|
||||
{mode === "personal" ? (
|
||||
<>
|
||||
<SourceCard
|
||||
|
|
@ -565,7 +565,7 @@ export function StepSources({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 right-0 top-full mt-6 px-1">
|
||||
<div className="mt-4 px-1 md:absolute md:left-0 md:right-0 md:top-full md:mt-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -579,7 +579,7 @@ export function StepSources({
|
|||
)}
|
||||
/>
|
||||
More integrations
|
||||
<span className="text-[#525D6E]">
|
||||
<span className="hidden text-[#525D6E] sm:inline">
|
||||
(Gmail, GitHub, OneDrive…)
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -591,7 +591,7 @@ export function StepSources({
|
|||
</div>
|
||||
|
||||
{moreOpen ? (
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4">
|
||||
<div className="mt-4 grid gap-3 md:mt-10 md:grid-cols-3 md:gap-4">
|
||||
<MoreSourcesGrid
|
||||
mode={mode}
|
||||
values={values}
|
||||
|
|
@ -929,22 +929,21 @@ function SourceActions({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-6 flex items-center justify-end gap-[22px] px-1",
|
||||
"mt-4 flex items-center justify-end gap-3 px-1 md:mt-6 md:gap-[22px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
|
||||
className="hidden text-[#737373] font-medium text-[14px] transition-colors hover:text-[#999] sm:inline"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={connectedCount === 0}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
|
||||
>
|
||||
Continue
|
||||
{connectedCount > 0 && (
|
||||
|
|
@ -1276,16 +1275,16 @@ function SourceCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[190px] rounded-[18px] p-5 transition-colors bg-[#1B1F24] flex flex-col",
|
||||
"min-h-[146px] rounded-[18px] bg-[#1B1F24] p-3.5 transition-colors flex flex-col md:min-h-[190px] md:p-5",
|
||||
isDone && "ring-1 ring-[#2261CA33]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||
<div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-start gap-2.5 md:grid-cols-[48px_minmax(0,1fr)_auto] md:gap-3">
|
||||
<div className="pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
"size-12 rounded-[12px] flex items-center justify-center shrink-0",
|
||||
"size-10 rounded-[12px] flex items-center justify-center shrink-0 md:size-12",
|
||||
bareIconFrame
|
||||
? "bg-transparent border border-transparent"
|
||||
: "bg-[#14161A] border border-[rgba(82,89,102,0.2)]",
|
||||
|
|
@ -1296,10 +1295,10 @@ function SourceCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 pr-1">
|
||||
<p className="text-[15px] leading-tight font-semibold text-[#fafafa]">
|
||||
<p className="text-[14px] leading-tight font-semibold text-[#fafafa] md:text-[15px]">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#737373] mt-1 leading-[1.35] font-medium">
|
||||
<p className="mt-0.5 text-[11.5px] text-[#737373] leading-[1.3] font-medium md:mt-1 md:text-[12px] md:leading-[1.35]">
|
||||
{blurb}
|
||||
</p>
|
||||
{headerNote}
|
||||
|
|
@ -1320,7 +1319,7 @@ function SourceCard({
|
|||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full h-9 px-4 text-[13px] font-medium text-[#fafafa] gap-1.5",
|
||||
"h-8 shrink-0 rounded-full px-3 text-[12px] font-medium text-[#fafafa] gap-1.5 md:h-9 md:px-4 md:text-[13px]",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
|
|
@ -1336,15 +1335,15 @@ function SourceCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
<ul className="mt-3 space-y-1 md:mt-4 md:space-y-1.5">
|
||||
{perks.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-start gap-2.5 text-[12px] text-[#737373] font-medium leading-[1.5]"
|
||||
className="flex items-start gap-2 text-[11px] text-[#737373] font-medium leading-[1.35] md:gap-2.5 md:text-[12px] md:leading-[1.5]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[7px]"
|
||||
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[6px] md:mt-[7px]"
|
||||
/>
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
|
|
@ -1352,7 +1351,7 @@ function SourceCard({
|
|||
</ul>
|
||||
|
||||
{(footerLeft || footerRight) && (
|
||||
<div className="mt-auto pt-4 flex items-end justify-between gap-3">
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-3 md:pt-4">
|
||||
<div>{footerLeft}</div>
|
||||
<div className="pb-1.5">{footerRight}</div>
|
||||
</div>
|
||||
|
|
@ -1364,10 +1363,10 @@ function SourceCard({
|
|||
function SpaceChip({ name }: { name: string }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373]"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-[#737373] md:gap-1.5 md:text-[11px]"
|
||||
title={`This source will save into the "${name}" space.`}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
<span className="text-[9px] uppercase tracking-[0.08em] text-[#525D6E] md:text-[10px]">
|
||||
Saves to
|
||||
</span>
|
||||
<FolderOpen className="size-3 text-[#737373]" />
|
||||
|
|
|
|||
|
|
@ -144,14 +144,14 @@ export function StepTeam({
|
|||
const count = values.invites.length
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto pb-24 md:pb-0">
|
||||
<section
|
||||
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
|
||||
className="rounded-[22px] bg-[#1B1F24] p-5 md:p-8"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-start gap-3 md: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"
|
||||
className="size-11 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0 md:size-12"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<Users className="size-5 text-[#fafafa]" />
|
||||
|
|
@ -159,13 +159,13 @@ export function StepTeam({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[20px] font-semibold text-[#fafafa]",
|
||||
"text-[19px] font-semibold text-[#fafafa] md:text-[20px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Invite your team
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
|
||||
<p className="text-[13px] text-[#737373] mt-1 leading-[1.45] font-medium md:text-[14px] md:leading-[1.5]">
|
||||
A brain gets sharper as more people contribute. You can also do
|
||||
this later.
|
||||
</p>
|
||||
|
|
@ -177,9 +177,9 @@ export function StepTeam({
|
|||
e.preventDefault()
|
||||
addInvites(draft)
|
||||
}}
|
||||
className="mt-6 flex gap-2"
|
||||
className="mt-5 flex min-w-0 gap-2 md:mt-6"
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
|
||||
<Input
|
||||
value={draft}
|
||||
|
|
@ -191,8 +191,8 @@ export function StepTeam({
|
|||
addInvites(pasted)
|
||||
}
|
||||
}}
|
||||
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
|
||||
className={cn(inputClass, "pl-10")}
|
||||
placeholder={`alex@${domainOrFallback}`}
|
||||
className={cn(inputClass, "min-w-0 pl-10")}
|
||||
style={inputBevelStyle}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -200,19 +200,19 @@ export function StepTeam({
|
|||
type="submit"
|
||||
variant="insideOut"
|
||||
disabled={!draft.trim()}
|
||||
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
className="h-12 shrink-0 rounded-full px-3.5 text-[13px] font-medium text-[#fafafa] md:px-4"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
|
||||
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-1 md: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">
|
||||
<div className="mt-5 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-4 py-7 text-center md:mt-6 md:px-5 md:py-8">
|
||||
<p className="text-[13px] text-[#737373] font-medium">
|
||||
No invites yet.
|
||||
</p>
|
||||
|
|
@ -242,11 +242,11 @@ export function StepTeam({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="mt-3 space-y-1.5 max-h-[240px] overflow-y-auto scrollbar-thin pr-1 md:max-h-[280px]">
|
||||
{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"
|
||||
className="flex flex-wrap items-center gap-2 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2 sm:flex-nowrap md:gap-3"
|
||||
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">
|
||||
|
|
@ -263,7 +263,7 @@ export function StepTeam({
|
|||
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">
|
||||
<SelectTrigger className="h-7 w-24 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]">
|
||||
|
|
@ -295,12 +295,12 @@ export function StepTeam({
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<div className="mt-5 flex items-center justify-end gap-3 border-t border-white/[0.06] pt-4 md:mt-6 md:gap-[22px] md:pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip ?? onContinue}
|
||||
disabled={submitting}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
|
||||
className="text-[#737373] font-medium text-[13px] transition-colors hover:text-[#999] disabled:opacity-50 md:text-[14px]"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
|
|
@ -308,7 +308,7 @@ export function StepTeam({
|
|||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
disabled={submitting}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
className="rounded-full px-4 py-2 text-[13px] font-medium text-[#fafafa] md:px-5 md:py-[10px]"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { getBrainTrialInfo } from "@/lib/billing-utils"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { getCanceledSubscription } from "@lib/queries"
|
||||
|
|
@ -201,6 +203,42 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
|
|||
},
|
||||
]
|
||||
|
||||
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
|
||||
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
||||
{
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
price: "$399",
|
||||
period: "/mo",
|
||||
credits: "$600",
|
||||
productId: "api_scale",
|
||||
description: "Company Brain for your team, with production usage",
|
||||
mostPopular: true,
|
||||
features: [
|
||||
"Company Brain Slack agent & shared memory",
|
||||
"$600 monthly usage credits when paid",
|
||||
"Auto top-up & spend caps",
|
||||
"Team connectors & dedicated support",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
period: "",
|
||||
credits: "Unlimited",
|
||||
productId: "api_enterprise",
|
||||
description: "Custom deployments with dedicated engineering",
|
||||
includesFrom: "Scale",
|
||||
features: [
|
||||
"Custom metering & billing",
|
||||
"Custom integrations & SSO",
|
||||
"Forward-deployed engineer",
|
||||
],
|
||||
isContactSales: true,
|
||||
},
|
||||
]
|
||||
|
||||
const PLAN_RANK: Record<PlanCardDefinition["id"], number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
|
|
@ -478,6 +516,11 @@ export default function Billing() {
|
|||
const { user, org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const posthog = usePostHog()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const brainTrial = useMemo(
|
||||
() => getBrainTrialInfo(org?.metadata as Record<string, unknown> | string),
|
||||
[org?.metadata],
|
||||
)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isResuming, setIsResuming] = useState(false)
|
||||
|
|
@ -511,20 +554,57 @@ export default function Billing() {
|
|||
planUsagePct,
|
||||
currentPlan,
|
||||
hasPaidPlan,
|
||||
isTrialing: autumnTrialing,
|
||||
trialEndsAtMs: autumnTrialEndsAtMs,
|
||||
isLoading: isCheckingStatus,
|
||||
daysRemaining,
|
||||
} = useTokenUsage(autumn)
|
||||
|
||||
const brainTrialStillOpen =
|
||||
brainTrial.status === "active" &&
|
||||
(brainTrial.endsAtMs == null || brainTrial.endsAtMs > Date.now())
|
||||
const isBrainTrialEnded =
|
||||
isCompanyBrain &&
|
||||
(brainTrial.status === "expired" ||
|
||||
brainTrial.status === "exhausted" ||
|
||||
(brainTrial.status === "active" &&
|
||||
brainTrial.endsAtMs != null &&
|
||||
brainTrial.endsAtMs <= Date.now()))
|
||||
const isOnTrial =
|
||||
!isBrainTrialEnded &&
|
||||
(autumnTrialing || (isCompanyBrain && brainTrialStillOpen))
|
||||
const trialEndsAtMs = brainTrial.endsAtMs ?? autumnTrialEndsAtMs ?? null
|
||||
const trialDaysLeft =
|
||||
brainTrial.daysRemaining ??
|
||||
(trialEndsAtMs != null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil((trialEndsAtMs - Date.now()) / (1000 * 60 * 60 * 24)),
|
||||
)
|
||||
: null)
|
||||
const trialEndsLabel =
|
||||
trialEndsAtMs != null
|
||||
? new Date(trialEndsAtMs).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: null
|
||||
const trialCredits = brainTrial.credits ?? (isOnTrial ? 200 : null)
|
||||
const showPlanUsage = hasPaidPlan || isOnTrial || isCompanyBrain
|
||||
|
||||
// Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2).
|
||||
// Company Brain orgs only list Scale + Enterprise — no carousel.
|
||||
const didAutoOpenPlanPage = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isCompanyBrain) return
|
||||
if (didAutoOpenPlanPage.current || isCheckingStatus) return
|
||||
didAutoOpenPlanPage.current = true
|
||||
if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) {
|
||||
setIsPlanCarouselActive(true)
|
||||
setPlanPage(1)
|
||||
}
|
||||
}, [isCheckingStatus, currentPlan])
|
||||
}, [isCheckingStatus, currentPlan, isCompanyBrain])
|
||||
|
||||
const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID]
|
||||
const creditRemaining =
|
||||
|
|
@ -834,6 +914,25 @@ export default function Billing() {
|
|||
)
|
||||
}
|
||||
|
||||
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
|
||||
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpgrade("api_scale")}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
PLAN_CARD_ACTION_CLASS,
|
||||
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
|
||||
)}
|
||||
>
|
||||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||||
Activate Scale
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCurrentPlan) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -901,7 +1000,9 @@ export default function Billing() {
|
|||
)}
|
||||
>
|
||||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||||
Upgrade to {plan.name}
|
||||
{isCompanyBrain && plan.id === "scale"
|
||||
? "Activate Scale"
|
||||
: `Upgrade to ${plan.name}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -914,31 +1015,43 @@ export default function Billing() {
|
|||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[18px] tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{hasPaidPlan
|
||||
? `${planDisplayNames[currentPlan]} plan`
|
||||
: "Free plan"}
|
||||
{isOnTrial ||
|
||||
isBrainTrialEnded ||
|
||||
(isCompanyBrain && currentPlan === "scale")
|
||||
? "Scale plan"
|
||||
: hasPaidPlan
|
||||
? `${planDisplayNames[currentPlan]} plan`
|
||||
: "Free plan"}
|
||||
</p>
|
||||
<Pill
|
||||
tone={
|
||||
isPlanCanceling
|
||||
isPlanCanceling || isBrainTrialEnded
|
||||
? "warning"
|
||||
: hasPaidPlan
|
||||
: isOnTrial
|
||||
? "active"
|
||||
: "muted"
|
||||
: hasPaidPlan
|
||||
? "active"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{isPlanCanceling
|
||||
? "Cancelling"
|
||||
: hasPaidPlan
|
||||
? "Active"
|
||||
: "Free"}
|
||||
: isBrainTrialEnded
|
||||
? brainTrial.status === "exhausted"
|
||||
? "Credits used up"
|
||||
: "Trial ended"
|
||||
: isOnTrial
|
||||
? "Free trial"
|
||||
: hasPaidPlan
|
||||
? "Active"
|
||||
: "Free"}
|
||||
</Pill>
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -948,11 +1061,44 @@ export default function Billing() {
|
|||
)}
|
||||
>
|
||||
{isPlanCanceling
|
||||
? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}. You'll move to Free after that.`
|
||||
: hasPaidPlan
|
||||
? "Expanded memory, connections, and usage for this workspace."
|
||||
: "Upgrade when you need more workspace usage and integrations."}
|
||||
? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}.${isCompanyBrain ? "" : " You'll move to Free after that."}`
|
||||
: isOnTrial
|
||||
? [
|
||||
trialEndsLabel
|
||||
? `Ends ${trialEndsLabel}${trialDaysLeft != null ? ` · ${trialDaysLeft} day${trialDaysLeft !== 1 ? "s" : ""} left` : ""}`
|
||||
: null,
|
||||
trialCredits != null
|
||||
? `$${trialCredits} trial credits`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: isBrainTrialEnded
|
||||
? "Trial ended. Activate Scale to restore access."
|
||||
: hasPaidPlan
|
||||
? "Expanded memory, connections, and usage for this workspace."
|
||||
: "Upgrade when you need more workspace usage and integrations."}
|
||||
</p>
|
||||
{isBrainTrialEnded ? (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleUpgrade("api_scale")}
|
||||
disabled={
|
||||
isUpgrading || isCheckingStatus || autumn.isLoading
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-[7px] bg-[#0054AD] px-2.5 text-[12px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{isUpgrading ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Activate Scale
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -1216,51 +1362,64 @@ export default function Billing() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{showPlanUsage ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{isOnTrial ? "Trial credit usage" : "Plan usage"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tabular-nums text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{formatUsd(usdSpent)}
|
||||
{usdIncluded > 0 ? (
|
||||
<span className="font-medium text-[#737373]">
|
||||
{" "}
|
||||
/ {formatUsd(usdIncluded)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-2 text-[#A3A3A3]">
|
||||
{planUsagePct < 1 && planUsagePct > 0
|
||||
? "< 1"
|
||||
: Math.round(planUsagePct)}
|
||||
% used
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[#2E353D]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#4BA0FA] transition-all"
|
||||
style={{
|
||||
width: `${planUsagePct}%`,
|
||||
background:
|
||||
planUsagePct > 80
|
||||
? "#C73B1B"
|
||||
: "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#A3A3A3]",
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Plan usage
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tabular-nums text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{planUsagePct < 1 && planUsagePct > 0
|
||||
? "< 1"
|
||||
: Math.round(planUsagePct)}
|
||||
% used
|
||||
{isOnTrial
|
||||
? "Credits apply for the trial period"
|
||||
: daysRemaining !== null
|
||||
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||||
: "Usage resets with your billing cycle"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[#2E353D]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#4BA0FA] transition-all"
|
||||
style={{
|
||||
width: `${planUsagePct}%`,
|
||||
background:
|
||||
planUsagePct > 80
|
||||
? "#C73B1B"
|
||||
: "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{daysRemaining !== null
|
||||
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||||
: "Usage resets with your billing cycle"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
|
@ -1268,7 +1427,7 @@ export default function Billing() {
|
|||
<section id="billing-plans" className="flex flex-col gap-4">
|
||||
<SectionTitle
|
||||
aside={
|
||||
isPlanCarouselActive ? (
|
||||
!isCompanyBrain && isPlanCarouselActive ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1294,55 +1453,81 @@ export default function Billing() {
|
|||
>
|
||||
Plans
|
||||
</SectionTitle>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="flex gap-4 transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform:
|
||||
planPage === 1 ? "translateX(calc(-100% - 1rem))" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
|
||||
{PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-3">
|
||||
{ADVANCED_PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPlanCarouselActive ? null : (
|
||||
<div className="flex justify-end px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPlanCarouselActive(true)
|
||||
setPlanPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center justify-center gap-2 text-[13px] font-semibold text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<span className="relative after:absolute after:right-0 after:-bottom-0.5 after:left-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:after:scale-x-100">
|
||||
Other plans
|
||||
</span>
|
||||
<span className="translate-x-1 text-[15px]" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
{isCompanyBrain ? (
|
||||
<div className="grid w-full gap-4 md:grid-cols-2">
|
||||
{COMPANY_BRAIN_PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={
|
||||
plan.id === "scale" && isOnTrial && trialCredits != null
|
||||
? {
|
||||
...plan,
|
||||
credits: `$${trialCredits} trial / $600 paid`,
|
||||
description:
|
||||
"14-day free trial with Company Brain — activate anytime",
|
||||
}
|
||||
: plan
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="flex gap-4 transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform:
|
||||
planPage === 1 ? "translateX(calc(-100% - 1rem))" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
|
||||
{PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-3">
|
||||
{ADVANCED_PLAN_CARDS.map((plan) => (
|
||||
<PlanCard
|
||||
action={getPlanCardAction(plan)}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPlanCarouselActive ? null : (
|
||||
<div className="flex justify-end px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPlanCarouselActive(true)
|
||||
setPlanPage(1)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center justify-center gap-2 text-[13px] font-semibold text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<span className="relative after:absolute after:right-0 after:-bottom-0.5 after:left-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:after:scale-x-100">
|
||||
Other plans
|
||||
</span>
|
||||
<span
|
||||
className="translate-x-1 text-[15px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
|
@ -1567,16 +1752,16 @@ export default function Billing() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{hasPaidPlan ? (
|
||||
{hasPaidPlan || isOnTrial ? (
|
||||
<section className="flex flex-col gap-4">
|
||||
<SectionTitle>Credits</SectionTitle>
|
||||
<SettingsCard className="border border-dashed border-white/10 bg-[#14161A]/70">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
{isOnTrial ? (
|
||||
<SettingsCard>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Top-up credits
|
||||
Trial credits
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -1584,32 +1769,72 @@ export default function Billing() {
|
|||
"mt-2 text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{creditRemaining > 0
|
||||
? `${formatUsd(creditRemaining)} available`
|
||||
: "No top-up credits yet"}
|
||||
{formatUsd(creditRemaining)} remaining
|
||||
{usdIncluded > 0 ? (
|
||||
<span className="font-medium text-[#737373]">
|
||||
{" "}
|
||||
of {formatUsd(usdIncluded)}
|
||||
</span>
|
||||
) : trialCredits != null ? (
|
||||
<span className="font-medium text-[#737373]">
|
||||
{" "}
|
||||
of {formatUsd(trialCredits)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] leading-snug text-[#737373]">
|
||||
Optional add-on that{" "}
|
||||
Company Brain trials include{" "}
|
||||
<span className="font-semibold text-[#A3A3A3]">
|
||||
rolls over
|
||||
${trialCredits ?? 200}
|
||||
</span>{" "}
|
||||
month-to-month, separate from your monthly usage above.
|
||||
in usage credits. Paid Scale includes $600/mo. Top-ups are
|
||||
available after you activate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreditsDialogOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{creditRemaining > 0 ? "Add more" : "Buy credits"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard className="border border-dashed border-white/10 bg-[#14161A]/70">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||||
Top-up credits
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{creditRemaining > 0
|
||||
? `${formatUsd(creditRemaining)} available`
|
||||
: "No top-up credits yet"}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] leading-snug text-[#737373]">
|
||||
Optional add-on that{" "}
|
||||
<span className="font-semibold text-[#A3A3A3]">
|
||||
rolls over
|
||||
</span>{" "}
|
||||
month-to-month, separate from your monthly usage above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreditsDialogOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{creditRemaining > 0 ? "Add more" : "Buy credits"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
998
apps/web/components/settings/company-brain-automations.tsx
Normal file
998
apps/web/components/settings/company-brain-automations.tsx
Normal file
|
|
@ -0,0 +1,998 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
LifeBuoy,
|
||||
ListTodo,
|
||||
Loader2,
|
||||
MessageCircleQuestion,
|
||||
Plus,
|
||||
Radar,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/automations`
|
||||
|
||||
type Automation = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
title: string
|
||||
channelId: string
|
||||
deliverTo: "channel" | "dm"
|
||||
prompt: string
|
||||
cron: string
|
||||
timezone: string | null
|
||||
createdBy: string | null
|
||||
}
|
||||
type Channel = { id: string; name: string; isPrivate: boolean }
|
||||
type Frequency = "daily" | "weekly"
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
const DEFAULT_PROMPT =
|
||||
"Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap."
|
||||
|
||||
// Local day/time -> UTC cron; the Date roundtrip carries any day rollover.
|
||||
function toUtcCron(
|
||||
time: string,
|
||||
frequency: Frequency,
|
||||
weekday: number,
|
||||
): string | null {
|
||||
const [hh, mm] = time.split(":").map(Number)
|
||||
if (
|
||||
hh === undefined ||
|
||||
mm === undefined ||
|
||||
Number.isNaN(hh) ||
|
||||
Number.isNaN(mm)
|
||||
)
|
||||
return null
|
||||
const d = new Date()
|
||||
d.setHours(hh, mm, 0, 0)
|
||||
if (frequency === "weekly")
|
||||
d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7))
|
||||
const m = d.getUTCMinutes()
|
||||
const h = d.getUTCHours()
|
||||
return frequency === "daily"
|
||||
? `${m} ${h} * * *`
|
||||
: `${m} ${h} * * ${d.getUTCDay()}`
|
||||
}
|
||||
|
||||
function fromUtcCron(
|
||||
cron: string,
|
||||
): { frequency: Frequency; weekday: number; time: string } | null {
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
if (parts.length !== 5) return null
|
||||
const [min, hr, , , dow] = parts
|
||||
const mm = Number(min)
|
||||
const hh = Number(hr)
|
||||
if (Number.isNaN(mm) || Number.isNaN(hh)) return null
|
||||
const d = new Date()
|
||||
d.setUTCHours(hh, mm, 0, 0)
|
||||
const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||
if (dow === "*") return { frequency: "daily", weekday: 1, time }
|
||||
const targetDow = Number(dow)
|
||||
if (Number.isNaN(targetDow)) return null
|
||||
d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7))
|
||||
return { frequency: "weekly", weekday: d.getDay(), time }
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
title: string
|
||||
channelId: string
|
||||
deliverTo: "channel" | "dm"
|
||||
prompt: string
|
||||
frequency: Frequency
|
||||
weekday: number
|
||||
time: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function toDraft(a: Automation): Draft {
|
||||
const parsed = fromUtcCron(a.cron)
|
||||
return {
|
||||
title: a.title,
|
||||
channelId: a.channelId,
|
||||
deliverTo: a.deliverTo === "dm" ? "dm" : "channel",
|
||||
prompt: a.prompt,
|
||||
frequency: parsed?.frequency ?? "daily",
|
||||
weekday: parsed?.weekday ?? 1,
|
||||
time: parsed?.time ?? "09:00",
|
||||
enabled: a.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
const emptyDraft = (): Draft => ({
|
||||
title: "",
|
||||
channelId: "",
|
||||
deliverTo: "channel",
|
||||
prompt: DEFAULT_PROMPT,
|
||||
frequency: "daily",
|
||||
weekday: 1,
|
||||
time: "09:00",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
type Category = "team" | "engineering" | "support" | "product"
|
||||
|
||||
type Preset = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
category: Category
|
||||
requiresApps?: string[]
|
||||
prompt: string
|
||||
frequency: Frequency
|
||||
weekday?: number
|
||||
time: string
|
||||
}
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
id: "standup",
|
||||
category: "team",
|
||||
label: "Morning checkup",
|
||||
description:
|
||||
"Shipped work, decisions, blockers & open questions from the last 24h.",
|
||||
icon: CalendarClock,
|
||||
prompt:
|
||||
"Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.",
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "weekly-recap",
|
||||
category: "team",
|
||||
label: "Company progress",
|
||||
description:
|
||||
"Decisions, shipped work & unresolved threads from the past week.",
|
||||
icon: CalendarClock,
|
||||
prompt:
|
||||
"Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.",
|
||||
frequency: "weekly",
|
||||
weekday: 1,
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "unanswered",
|
||||
category: "team",
|
||||
label: "Unanswered questions",
|
||||
description: "Questions in this channel from the last 24h with no reply.",
|
||||
icon: MessageCircleQuestion,
|
||||
prompt:
|
||||
"Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.",
|
||||
frequency: "daily",
|
||||
time: "16:00",
|
||||
},
|
||||
{
|
||||
id: "prs-review",
|
||||
category: "engineering",
|
||||
label: "PRs awaiting review",
|
||||
description: "Open PRs waiting on review; flags stale ones.",
|
||||
icon: GitPullRequest,
|
||||
requiresApps: ["github"],
|
||||
prompt:
|
||||
"List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.",
|
||||
frequency: "daily",
|
||||
time: "09:30",
|
||||
},
|
||||
{
|
||||
id: "issue-triage",
|
||||
category: "engineering",
|
||||
label: "Issue triage",
|
||||
description: "New or unassigned issues that need a response.",
|
||||
icon: ListTodo,
|
||||
requiresApps: ["github", "linear"],
|
||||
prompt:
|
||||
"Summarize new or unassigned issues from the last 24 hours that need triage or a response.",
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "customer-signal",
|
||||
category: "support",
|
||||
label: "Customer signal",
|
||||
description: "Recent customer issues & feedback and their status.",
|
||||
icon: LifeBuoy,
|
||||
requiresApps: ["plain", "linear"],
|
||||
prompt:
|
||||
"Recap customer issues and feedback raised recently across our tools and channels, with their current status.",
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "competitor-check",
|
||||
category: "product",
|
||||
label: "Competitor check",
|
||||
description: "What competitors shipped, announced, or changed this week.",
|
||||
icon: Radar,
|
||||
prompt:
|
||||
"Check what our competitors shipped, announced, or changed recently — launches, pricing changes, and anything the team should react to.",
|
||||
frequency: "weekly",
|
||||
weekday: 1,
|
||||
time: "09:00",
|
||||
},
|
||||
{
|
||||
id: "release-notes",
|
||||
category: "product",
|
||||
label: "Release notes draft",
|
||||
description: "Draft notes from PRs merged since the last digest.",
|
||||
icon: FileText,
|
||||
requiresApps: ["github"],
|
||||
prompt:
|
||||
"Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.",
|
||||
frequency: "weekly",
|
||||
weekday: 5,
|
||||
time: "16:00",
|
||||
},
|
||||
]
|
||||
|
||||
function presetToDraft(p: Preset): Draft {
|
||||
return {
|
||||
title: p.label,
|
||||
channelId: "",
|
||||
deliverTo: "channel",
|
||||
prompt: p.prompt,
|
||||
frequency: p.frequency,
|
||||
weekday: p.weekday ?? 1,
|
||||
time: p.time,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Connected-app presets first, universal next, unconnected-app presets last.
|
||||
function sortPresets(connected: Set<string>): Preset[] {
|
||||
const rank = (p: Preset) => {
|
||||
if (!p.requiresApps) return 1
|
||||
return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2
|
||||
}
|
||||
return [...PRESETS].sort((a, b) => rank(a) - rank(b))
|
||||
}
|
||||
|
||||
function cadenceLabel(p: Preset): string {
|
||||
if (p.frequency === "weekly")
|
||||
return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}`
|
||||
return `Daily · ${p.time}`
|
||||
}
|
||||
|
||||
const controlClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)
|
||||
const fieldLabel = cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")
|
||||
const selectContentClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
|
||||
)
|
||||
const selectItemClass =
|
||||
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
|
||||
const DM_VALUE = "__dm__"
|
||||
|
||||
function AutomationCard({
|
||||
initial,
|
||||
id,
|
||||
channels,
|
||||
ownerLabel,
|
||||
onDone,
|
||||
onCancelNew,
|
||||
onCollapse,
|
||||
}: {
|
||||
initial: Draft
|
||||
id: string | null
|
||||
channels: Channel[]
|
||||
ownerLabel?: string
|
||||
onDone: () => void
|
||||
onCancelNew?: () => void
|
||||
onCollapse?: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(initial)
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) =>
|
||||
setDraft((d) => ({ ...d, [k]: v }))
|
||||
|
||||
const body = () => {
|
||||
if (!draft.title.trim()) throw new Error("Give the automation a name.")
|
||||
if (draft.deliverTo === "channel" && !draft.channelId)
|
||||
throw new Error("Pick a channel to post to.")
|
||||
const cron = toUtcCron(draft.time, draft.frequency, draft.weekday)
|
||||
if (!cron) throw new Error("Pick a valid time.")
|
||||
return {
|
||||
title: draft.title.trim(),
|
||||
deliverTo: draft.deliverTo,
|
||||
channelId: draft.deliverTo === "dm" ? null : draft.channelId,
|
||||
prompt: draft.prompt.trim() || DEFAULT_PROMPT,
|
||||
cron,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: draft.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = body()
|
||||
const res = await fetch(id ? `${BASE}/${id}` : `${BASE}/`, {
|
||||
method: id ? "PUT" : "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("You can only manage automations you created.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(b.error ?? "Couldn't save.")
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Automation saved.")
|
||||
onDone()
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't save."),
|
||||
})
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!id) throw new Error("Save the automation first.")
|
||||
const res = await fetch(`${BASE}/${id}/run-now`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
ok?: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok || b.ok === false)
|
||||
throw new Error(b.reason ?? b.error ?? "Couldn't run.")
|
||||
},
|
||||
onSuccess: () => toast.success("Automation triggered — check the channel."),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't run."),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!id) return
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Couldn't delete.")
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Automation removed.")
|
||||
onDone()
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't delete."),
|
||||
})
|
||||
|
||||
const busy = save.isPending || trigger.isPending || remove.isPending
|
||||
const disabled = busy
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-3 rounded-[14px] bg-[#14161A] p-5",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<input
|
||||
className={cn(controlClass, "flex-1 font-medium")}
|
||||
disabled={disabled}
|
||||
placeholder="Automation name (e.g. Support morning recap)"
|
||||
value={draft.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
/>
|
||||
{ownerLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-full border border-white/10 px-2 py-1 text-[11px] text-[#9A9A9A]",
|
||||
)}
|
||||
>
|
||||
{ownerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{onCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
title="Collapse"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA]"
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.enabled}
|
||||
disabled={disabled}
|
||||
onClick={() => set("enabled", !draft.enabled)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
draft.enabled ? "bg-[#2563FF]" : "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block size-4 rounded-full bg-white shadow-sm transition-transform",
|
||||
draft.enabled ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="flex min-h-[200px] flex-col gap-1">
|
||||
<span className={fieldLabel}>Prompt</span>
|
||||
<textarea
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-full min-h-[120px] w-full flex-1 resize-none rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 py-2 text-[13px] leading-[1.5] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={DEFAULT_PROMPT}
|
||||
value={draft.prompt}
|
||||
onChange={(e) => set("prompt", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className={cn(fieldLabel, "flex items-center gap-1")}>
|
||||
Deliver to
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-[#6B6B6B] hover:text-[#9A9A9A]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Info className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[260px]">
|
||||
Only channels Company Brain has been added to are listed —
|
||||
invite the bot to a channel to use it. A DM goes privately
|
||||
to you and can also read your personal connections.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
<Select
|
||||
value={draft.deliverTo === "dm" ? DM_VALUE : draft.channelId}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) =>
|
||||
v === DM_VALUE
|
||||
? { ...d, deliverTo: "dm", channelId: "" }
|
||||
: { ...d, deliverTo: "channel", channelId: v },
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue placeholder="Select a channel…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
<SelectItem value={DM_VALUE} className={selectItemClass}>
|
||||
📩 Direct message to me
|
||||
</SelectItem>
|
||||
{channels.map((ch) => (
|
||||
<SelectItem
|
||||
key={ch.id}
|
||||
value={ch.id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{ch.isPrivate ? "🔒 " : "# "}
|
||||
{ch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className={fieldLabel}>Frequency</span>
|
||||
<Select
|
||||
value={draft.frequency}
|
||||
onValueChange={(v) => set("frequency", v as Frequency)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
<SelectItem value="daily" className={selectItemClass}>
|
||||
Daily
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly" className={selectItemClass}>
|
||||
Weekly
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{draft.frequency === "weekly" ? (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className={fieldLabel}>Day</span>
|
||||
<Select
|
||||
value={String(draft.weekday)}
|
||||
onValueChange={(v) => set("weekday", Number(v))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
{WEEKDAYS.map((label, i) => (
|
||||
<SelectItem
|
||||
key={label}
|
||||
value={String(i)}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className={fieldLabel}>Time</span>
|
||||
<input
|
||||
type="time"
|
||||
className={controlClass}
|
||||
disabled={disabled}
|
||||
value={draft.time}
|
||||
onChange={(e) => set("time", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{id ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => remove.mutate()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex size-9 items-center justify-center rounded-full text-[#8A5247] transition-colors hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)}
|
||||
title="Delete automation"
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : onCancelNew ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onCancelNew}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center rounded-full px-3 text-[13px] text-[#9A9A9A] transition-colors hover:text-[#FAFAFA] disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{id ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => trigger.mutate()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full border border-white/10 bg-transparent px-4 text-[13px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
{trigger.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{trigger.isPending ? "Running…" : "Run now"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => save.mutate()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{save.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutomationRow({
|
||||
automation,
|
||||
channels,
|
||||
ownerLabel,
|
||||
onExpand,
|
||||
onChanged,
|
||||
}: {
|
||||
automation: Automation
|
||||
channels: Channel[]
|
||||
ownerLabel?: string
|
||||
onExpand: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const parsed = fromUtcCron(automation.cron)
|
||||
const target =
|
||||
automation.deliverTo === "dm"
|
||||
? "DM to you"
|
||||
: `#${channels.find((c) => c.id === automation.channelId)?.name ?? "channel"}`
|
||||
const cadence = parsed
|
||||
? parsed.frequency === "weekly"
|
||||
? `Weekly ${WEEKDAYS[parsed.weekday] ?? "Mon"} ${parsed.time}`
|
||||
: `Daily ${parsed.time}`
|
||||
: "Not scheduled"
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`${BASE}/${automation.id}`, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: automation.title,
|
||||
deliverTo: automation.deliverTo,
|
||||
channelId:
|
||||
automation.deliverTo === "dm" ? null : automation.channelId,
|
||||
prompt: automation.prompt,
|
||||
cron: automation.cron,
|
||||
timezone: automation.timezone,
|
||||
enabled: !automation.enabled,
|
||||
}),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("You can only manage automations you created.")
|
||||
if (!res.ok) throw new Error("Couldn't update.")
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't update."),
|
||||
})
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`${BASE}/${automation.id}/run-now`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
ok?: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok || b.ok === false)
|
||||
throw new Error(b.reason ?? b.error ?? "Couldn't run.")
|
||||
},
|
||||
onSuccess: () => toast.success("Automation triggered — check the channel."),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't run."),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-[14px] bg-[#14161A] px-4 py-3",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={automation.enabled}
|
||||
disabled={toggle.isPending}
|
||||
onClick={() => toggle.mutate()}
|
||||
title={automation.enabled ? "Disable" : "Enable"}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:opacity-50",
|
||||
automation.enabled ? "bg-[#2563FF]" : "bg-white/10",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block size-4 rounded-full bg-white shadow-sm transition-transform",
|
||||
automation.enabled ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex min-w-0 flex-1 flex-col text-left"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{automation.title}
|
||||
</span>
|
||||
{ownerLabel ? (
|
||||
<span className="shrink-0 rounded-full border border-white/10 px-1.5 py-0.5 text-[10px] text-[#9A9A9A]">
|
||||
{ownerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-[12px] text-[#6B6B6B]">
|
||||
{target} · {cadence}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={trigger.isPending}
|
||||
onClick={() => trigger.mutate()}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-3 text-[12px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
{trigger.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Run now
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
title="Edit"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA]"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
onPick,
|
||||
}: {
|
||||
preset: Preset
|
||||
onPick: () => void
|
||||
}) {
|
||||
const Icon = preset.icon
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-w-0 cursor-pointer flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 text-left",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] transition-colors hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<Icon className="size-4 text-[#9A9A9A]" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{preset.label}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]">
|
||||
{preset.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<span className="text-[12px] font-medium text-[#737373]">
|
||||
{cadenceLabel(preset)}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-[#8B929E]">
|
||||
Use template
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainAutomations() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { user, org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const draftKey = useRef(0)
|
||||
const addDraft = (draft: Draft) =>
|
||||
setDrafts((d) => [...d, { key: draftKey.current++, draft }])
|
||||
const removeDraft = (key: number) =>
|
||||
setDrafts((d) => d.filter((x) => x.key !== key))
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "list", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("failed")
|
||||
return ((await res.json()) as { automations: Automation[] }).automations
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "channels", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE}/channels`, { credentials: "include" })
|
||||
if (!res.ok) return [] as Channel[]
|
||||
return ((await res.json()) as { channels: Channel[] }).channels ?? []
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
||||
const appsQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "apps", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return [] as string[]
|
||||
const body = (await res.json()) as {
|
||||
connections?: { serverSlug: string }[]
|
||||
}
|
||||
return (body.connections ?? []).map((c) => c.serverSlug)
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
||||
if (!isCompanyBrain) return null
|
||||
|
||||
const channels = channelsQuery.data ?? []
|
||||
const automations = listQuery.data ?? []
|
||||
const presets = sortPresets(new Set(appsQuery.data ?? []))
|
||||
const nameFor = (userId: string | null): string | undefined => {
|
||||
if (!userId) return undefined
|
||||
if (userId === user?.id) return "You"
|
||||
const m = org?.members?.find((mem) => mem.userId === userId)
|
||||
return m?.user?.name ?? m?.user?.email ?? "A teammate"
|
||||
}
|
||||
const refresh = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["company-brain-automations", "list"],
|
||||
})
|
||||
}
|
||||
const usedTitles = new Set(automations.map((a) => a.title))
|
||||
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
|
||||
const hasList = automations.length > 0 || drafts.length > 0
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
{automations.map((a) =>
|
||||
openId === a.id ? (
|
||||
<AutomationCard
|
||||
key={a.id}
|
||||
id={a.id}
|
||||
initial={toDraft(a)}
|
||||
channels={channels}
|
||||
onDone={() => {
|
||||
setOpenId(null)
|
||||
refresh()
|
||||
}}
|
||||
onCollapse={() => setOpenId(null)}
|
||||
/>
|
||||
) : (
|
||||
<AutomationRow
|
||||
key={a.id}
|
||||
automation={a}
|
||||
channels={channels}
|
||||
ownerLabel={
|
||||
a.createdBy && a.createdBy !== user?.id
|
||||
? nameFor(a.createdBy)
|
||||
: undefined
|
||||
}
|
||||
onExpand={() => setOpenId(a.id)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{drafts.map(({ key, draft }) => (
|
||||
<AutomationCard
|
||||
key={key}
|
||||
id={null}
|
||||
initial={draft}
|
||||
channels={channels}
|
||||
onDone={() => {
|
||||
removeDraft(key)
|
||||
refresh()
|
||||
}}
|
||||
onCancelNew={() => removeDraft(key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasList ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
Templates
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availablePresets.map((p) => (
|
||||
<PresetCard
|
||||
key={p.id}
|
||||
preset={p}
|
||||
onPick={() => addDraft(presetToDraft(p))}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Loader2, Lock } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
|
@ -33,7 +45,6 @@ type ConnRow = {
|
|||
userId: string | null
|
||||
}
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
type Scope = "org" | "user"
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
|
|
@ -48,28 +59,20 @@ function slugifyMcpName(value: string) {
|
|||
.slice(0, 63)
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full border border-[#1E293B] bg-[#0D121A] px-4 h-9",
|
||||
"text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
const pillLinkClass = cn(
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
)
|
||||
|
||||
function StatusDot({ connected }: { connected: boolean }) {
|
||||
function ScopeChip({
|
||||
label,
|
||||
connected,
|
||||
}: {
|
||||
label: string
|
||||
connected: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -84,19 +87,22 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const menuItemClass =
|
||||
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
||||
function AppCard({
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
connected,
|
||||
canConnect,
|
||||
canDisconnect,
|
||||
lockedHint,
|
||||
userConnected,
|
||||
orgConnected,
|
||||
isAdmin,
|
||||
personalOnly,
|
||||
busy,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
|
|
@ -104,16 +110,20 @@ function AppCard({
|
|||
name: string
|
||||
subtitle: string
|
||||
icon: React.ReactNode
|
||||
connected: boolean
|
||||
canConnect: boolean
|
||||
canDisconnect: boolean
|
||||
lockedHint?: string
|
||||
userConnected: boolean
|
||||
orgConnected: boolean
|
||||
isAdmin: boolean
|
||||
personalOnly?: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
onConnect: (shared: boolean) => void
|
||||
onDisconnect: (shared: boolean) => void
|
||||
}) {
|
||||
const anyConnected = userConnected || orgConnected
|
||||
const showOrgChip = !personalOnly && (orgConnected || isAdmin)
|
||||
const adminMenu = isAdmin && !personalOnly
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
|
|
@ -138,67 +148,154 @@ function AppCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<StatusDot connected={connected} />
|
||||
{connected && canDisconnect ? (
|
||||
<PillButton onClick={onDisconnect} disabled={busy}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{personalOnly || !anyConnected ? (
|
||||
<ScopeChip
|
||||
label={userConnected ? "Connected" : "Not connected"}
|
||||
connected={userConnected}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ScopeChip label="You" connected={userConnected} />
|
||||
{showOrgChip ? (
|
||||
<ScopeChip label="Workspace" connected={orgConnected} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{adminMenu ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{anyConnected ? "Manage" : "Connect"}
|
||||
<ChevronDown className="size-3.5 text-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"min-w-[220px] rounded-xl border border-white/[0.08] p-1.5 shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className={menuItemClass}
|
||||
onClick={() =>
|
||||
userConnected ? onDisconnect(false) : onConnect(false)
|
||||
}
|
||||
>
|
||||
{userConnected ? "Disconnect my account" : "Connect my account"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={menuItemClass}
|
||||
onClick={() =>
|
||||
orgConnected ? onDisconnect(true) : onConnect(true)
|
||||
}
|
||||
>
|
||||
{orgConnected
|
||||
? "Disconnect workspace"
|
||||
: "Connect for workspace"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : userConnected ? (
|
||||
<PillButton onClick={() => onDisconnect(false)} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Disconnect
|
||||
</PillButton>
|
||||
) : (
|
||||
!connected &&
|
||||
(canConnect ? (
|
||||
<PillButton onClick={onConnect} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</PillButton>
|
||||
) : lockedHint ? (
|
||||
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
|
||||
<Lock className="size-3" />
|
||||
{lockedHint}
|
||||
</span>
|
||||
) : null)
|
||||
) : personalOnly ? null : (
|
||||
<PillButton onClick={() => onConnect(false)} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
onChange,
|
||||
function SlackCard({
|
||||
status,
|
||||
isAdmin,
|
||||
installHref,
|
||||
}: {
|
||||
scope: Scope
|
||||
onChange: (s: Scope) => void
|
||||
status: SlackStatus | null
|
||||
isAdmin: boolean
|
||||
installHref: string
|
||||
}) {
|
||||
const items: { id: Scope; label: string }[] = [
|
||||
{ id: "org", label: "Organization" },
|
||||
{ id: "user", label: "Personal" },
|
||||
]
|
||||
const connected = status?.connected ?? false
|
||||
return (
|
||||
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => onChange(it.id)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
|
||||
scope === it.id
|
||||
? "bg-[#1E293B] text-[#FAFAFA]"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<SlackMark className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Slack
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Messaging
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<ScopeChip
|
||||
label={
|
||||
connected
|
||||
? status?.teamName
|
||||
? `Workspace · ${status.teamName}`
|
||||
: "Workspace"
|
||||
: "Not connected"
|
||||
}
|
||||
connected={connected}
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<a
|
||||
href={installHref}
|
||||
className={cn(dmSans125ClassName(), pillLinkClass)}
|
||||
>
|
||||
{connected ? "Reconnect" : "Connect"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowSkeleton() {
|
||||
return (
|
||||
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
|
|
@ -206,7 +303,7 @@ function RowSkeleton() {
|
|||
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
<div className="mt-5 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -219,19 +316,11 @@ export default function CompanyBrainConnections() {
|
|||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>("user")
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [customName, setCustomName] = useState("")
|
||||
const [customServerUrl, setCustomServerUrl] = useState("")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const role = (roleQuery.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [catRes, connRes, slackRes] = await Promise.all([
|
||||
|
|
@ -388,8 +477,12 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${slug} connected.`)
|
||||
setCustomOpen(false)
|
||||
setCustomName("")
|
||||
setCustomServerUrl("")
|
||||
await load()
|
||||
|
|
@ -461,43 +554,9 @@ export default function CompanyBrainConnections() {
|
|||
!catalogSlugs.has(row.serverSlug),
|
||||
)
|
||||
: []
|
||||
const shared = scope === "org"
|
||||
const description = shared
|
||||
? "Connected by admins. Used for reads when you haven't connected your own."
|
||||
: "Your personal accounts, used for your actions and your reads."
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
{slackStatus?.connected && slackStatus.teamName ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"ml-auto text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Slack · {slackStatus.teamName}
|
||||
</p>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<SecondaryButton href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Reconnect Slack
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-1 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{loading ? (
|
||||
<>
|
||||
<RowSkeleton />
|
||||
|
|
@ -506,100 +565,128 @@ export default function CompanyBrainConnections() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!shared && isStaff ? (
|
||||
<form
|
||||
onSubmit={connectCustom}
|
||||
className="flex min-h-[152px] min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Custom MCP server
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<SlackCard
|
||||
status={slackStatus}
|
||||
isAdmin={isAdmin}
|
||||
installHref={`${BACKEND}/brain/slack/oauth/install`}
|
||||
/>
|
||||
{apps.map((entry) => (
|
||||
<AppCard
|
||||
key={`${scope}-${entry.slug}`}
|
||||
key={entry.slug}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
userConnected={isConnected(entry.slug, false)}
|
||||
orgConnected={isConnected(entry.slug, true)}
|
||||
isAdmin={isAdmin}
|
||||
busy={busy?.startsWith(`${entry.slug}:`) ?? false}
|
||||
onConnect={(shared) => connect(entry, shared)}
|
||||
onDisconnect={(shared) => disconnect(entry, shared)}
|
||||
/>
|
||||
))}
|
||||
{!shared &&
|
||||
customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
connected
|
||||
canConnect={false}
|
||||
canDisconnect
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{customRows.map((row) => (
|
||||
<AppCard
|
||||
key={`custom-${row.serverSlug}`}
|
||||
name={titleCase(row.serverSlug.replace(/-/g, " "))}
|
||||
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
|
||||
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
|
||||
userConnected
|
||||
orgConnected={false}
|
||||
isAdmin={false}
|
||||
personalOnly
|
||||
busy={busy === `${row.serverSlug}:user`}
|
||||
onConnect={() => {}}
|
||||
onDisconnect={() =>
|
||||
disconnect(
|
||||
{
|
||||
slug: row.serverSlug,
|
||||
name: titleCase(row.serverSlug.replace(/-/g, " ")),
|
||||
category: "Custom OAuth MCP",
|
||||
authType: "oauth",
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{isStaff ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
|
||||
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add custom MCP
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[440px]! flex flex-col gap-4 rounded-[22px] border-none bg-[#1B1F24] p-4",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
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 items-start justify-between gap-4">
|
||||
<DialogHeader className="flex-1 space-y-1 pl-1">
|
||||
<DialogTitle className="font-semibold text-[#FAFAFA]">
|
||||
Custom MCP server
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
|
||||
Add a personal OAuth MCP server by URL.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<DialogPrimitive.Close
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] transition-opacity hover:opacity-100 focus:outline-hidden"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 text-[#737373]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<form onSubmit={connectCustom} className="flex flex-col gap-2">
|
||||
<input
|
||||
value={customName}
|
||||
onChange={(event) => setCustomName(event.target.value)}
|
||||
placeholder="Name"
|
||||
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<input
|
||||
value={customServerUrl}
|
||||
onChange={(event) => setCustomServerUrl(event.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
className="h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
|
||||
/>
|
||||
<div className="flex justify-end pt-2">
|
||||
<PillButton
|
||||
type="submit"
|
||||
disabled={busy?.startsWith("custom:") ?? false}
|
||||
>
|
||||
{busy?.startsWith("custom:") && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
Connect
|
||||
</PillButton>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
537
apps/web/components/settings/company-brain-models.tsx
Normal file
537
apps/web/components/settings/company-brain-models.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { Check, ChevronDown, Loader2, Lock } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select"
|
||||
import {
|
||||
type BrainModelConfig,
|
||||
type BrainModelRole,
|
||||
type BrainReasoningEffort,
|
||||
type BrainReasoningKey,
|
||||
useBrainModels,
|
||||
useUpdateBrainModels,
|
||||
} from "@/hooks/use-brain-models"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
"claude-sonnet-5": "Sonnet 5",
|
||||
"claude-opus-4.8": "Opus 4.8",
|
||||
"claude-sonnet-4.6": "Sonnet 4.6",
|
||||
"claude-haiku-4.5": "Haiku 4.5",
|
||||
"grok-4.5": "Grok 4.5",
|
||||
"grok-4.3": "Grok 4.3",
|
||||
"grok-4-fast": "Grok 4 Fast",
|
||||
"gpt-5.6": "GPT-5.6",
|
||||
"gpt-5.5": "GPT-5.5",
|
||||
}
|
||||
|
||||
const labelFor = (id: string) => MODEL_LABELS[id] ?? id
|
||||
|
||||
// One-line personality tags so non-experts can tell models apart.
|
||||
const MODEL_TAGS: Record<string, string> = {
|
||||
"claude-sonnet-5": "balanced",
|
||||
"claude-opus-4.8": "most capable, slower",
|
||||
"claude-sonnet-4.6": "balanced",
|
||||
"claude-haiku-4.5": "fast and light",
|
||||
"grok-4.5": "sharp all-rounder",
|
||||
"grok-4.3": "capable",
|
||||
"grok-4-fast": "fastest",
|
||||
"gpt-5.6": "capable",
|
||||
"gpt-5.5": "capable",
|
||||
}
|
||||
|
||||
const EFFORT_LABELS: Record<BrainReasoningEffort, string> = {
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
high: "High",
|
||||
xhigh: "Extra high",
|
||||
}
|
||||
|
||||
const ROWS: {
|
||||
role: BrainModelRole
|
||||
effortKey: BrainReasoningKey
|
||||
title: string
|
||||
help: string
|
||||
effortHelp: string
|
||||
}[] = [
|
||||
{
|
||||
role: "main",
|
||||
effortKey: "mainEffort",
|
||||
title: "Answers",
|
||||
help: "Writes the replies your brain sends in Slack.",
|
||||
effortHelp: "Deeper thinking gives better answers but takes longer.",
|
||||
},
|
||||
{
|
||||
role: "triage",
|
||||
effortKey: "triageEffort",
|
||||
title: "When to reply",
|
||||
help: "Decides whether and how the brain responds to a message.",
|
||||
effortHelp:
|
||||
"Deeper thinking routes messages more carefully but takes longer.",
|
||||
},
|
||||
{
|
||||
role: "research",
|
||||
effortKey: "researchEffort",
|
||||
title: "Web research",
|
||||
help: "Looks things up on the web when researching your company.",
|
||||
effortHelp: "Deeper research per web search, at the cost of speed.",
|
||||
},
|
||||
]
|
||||
|
||||
type FullConfig = Required<BrainModelConfig>
|
||||
|
||||
type PresetDef = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
build: (
|
||||
defaults: BrainModelConfig,
|
||||
choices: { main: string[] } & Partial<
|
||||
Record<BrainReasoningKey, BrainReasoningEffort[]>
|
||||
>,
|
||||
) => FullConfig
|
||||
}
|
||||
|
||||
const pickEffort = (
|
||||
options: BrainReasoningEffort[] | undefined,
|
||||
wanted: BrainReasoningEffort,
|
||||
fallback: BrainReasoningEffort,
|
||||
): BrainReasoningEffort =>
|
||||
!options || options.length === 0 || options.includes(wanted)
|
||||
? wanted
|
||||
: fallback
|
||||
|
||||
const PRESETS: PresetDef[] = [
|
||||
{
|
||||
id: "fast",
|
||||
label: "Fastest",
|
||||
description: "Snappy replies, lighter on credits. Best for quick lookups.",
|
||||
build: (defaults, choices) => ({
|
||||
main: choices.main.includes("grok-4-fast")
|
||||
? "grok-4-fast"
|
||||
: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "balanced",
|
||||
label: "Balanced",
|
||||
description: "Our recommended mix of speed and answer quality.",
|
||||
build: (defaults) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: defaults.mainEffort ?? "high",
|
||||
triageEffort: defaults.triageEffort ?? "low",
|
||||
researchEffort: defaults.researchEffort ?? "high",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "thorough",
|
||||
label: "Most thorough",
|
||||
description: "Deepest answers and research. Slower, uses more credits.",
|
||||
build: (defaults, choices) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
"main",
|
||||
"triage",
|
||||
"research",
|
||||
"mainEffort",
|
||||
"triageEffort",
|
||||
"researchEffort",
|
||||
] as const
|
||||
|
||||
const extraHighIsBounded = (model: string): boolean =>
|
||||
model.startsWith("grok-") || model.startsWith("gpt-")
|
||||
|
||||
const controlClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
|
||||
)
|
||||
const fieldLabel = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
|
||||
)
|
||||
const selectContentClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
|
||||
)
|
||||
const selectItemClass =
|
||||
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainModels({
|
||||
showHeading = true,
|
||||
}: {
|
||||
showHeading?: boolean
|
||||
}) {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
|
||||
const modelsQuery = useBrainModels(isCompanyBrain)
|
||||
const update = useUpdateBrainModels()
|
||||
|
||||
const [draft, setDraft] = useState<Partial<BrainModelConfig>>({})
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const resolved = modelsQuery.data?.resolved
|
||||
const defaults = modelsQuery.data?.defaults
|
||||
const choices = modelsQuery.data?.choices
|
||||
|
||||
const valueFor = (role: BrainModelRole): string =>
|
||||
draft[role] ?? resolved?.[role] ?? ""
|
||||
const effortFor = (key: BrainReasoningKey): BrainReasoningEffort | "" =>
|
||||
draft[key] ?? resolved?.[key] ?? defaults?.[key] ?? ""
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!resolved) return false
|
||||
return ROWS.some(({ role, effortKey }) => {
|
||||
const modelChanged =
|
||||
draft[role] !== undefined && draft[role] !== resolved[role]
|
||||
const effortChanged =
|
||||
draft[effortKey] !== undefined &&
|
||||
draft[effortKey] !== resolved[effortKey]
|
||||
return modelChanged || effortChanged
|
||||
})
|
||||
}, [draft, resolved])
|
||||
|
||||
const presets = useMemo(() => {
|
||||
if (!defaults || !choices) return []
|
||||
return PRESETS.map((p) => ({ ...p, config: p.build(defaults, choices) }))
|
||||
}, [defaults, choices])
|
||||
|
||||
// Preset whose full config matches what's currently on screen (draft over saved).
|
||||
const activePresetId = useMemo(() => {
|
||||
if (!resolved) return null
|
||||
const current: Record<string, string | undefined> = {}
|
||||
for (const key of CONFIG_KEYS) {
|
||||
current[key] = draft[key] ?? resolved[key] ?? defaults?.[key]
|
||||
}
|
||||
return (
|
||||
presets.find((p) =>
|
||||
CONFIG_KEYS.every((key) => current[key] === p.config[key]),
|
||||
)?.id ?? null
|
||||
)
|
||||
}, [presets, draft, resolved, defaults])
|
||||
|
||||
if (!isCompanyBrain) return null
|
||||
|
||||
const disabled = !isAdmin || modelsQuery.isLoading || update.isPending
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 px-1">
|
||||
{showHeading ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SectionTitle>Models</SectionTitle>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")}
|
||||
>
|
||||
Choose which models Company Brain uses. Applies to this organization
|
||||
only.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{modelsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
) : modelsQuery.isError ? (
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
|
||||
Couldn't load models.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{presets.map((preset) => {
|
||||
const isActive = preset.id === activePresetId
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
if (!resolved) return
|
||||
setDraft({ ...preset.config })
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-w-0 cursor-pointer flex-col gap-1.5 rounded-xl p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
isActive
|
||||
? "bg-[#10161f] ring-1 ring-[#2261CA]/45"
|
||||
: "hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
|
||||
{preset.label}
|
||||
{preset.id === "balanced" ? (
|
||||
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
|
||||
Recommended
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check className="size-4 shrink-0 text-[#6BB0FF]" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{preset.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex w-fit cursor-pointer items-center gap-1.5 rounded-full px-1 py-1 text-[12px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
(advancedOpen || activePresetId === null) && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
Advanced
|
||||
{activePresetId === null ? (
|
||||
<span className="text-[11px] text-[#5F6673]">
|
||||
· custom settings in use
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{advancedOpen || activePresetId === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ROWS.map(({ role, effortKey, title, help, effortHelp }) => {
|
||||
const options = choices?.[role] ?? []
|
||||
const current = valueFor(role)
|
||||
const effortOptions = choices?.[effortKey] ?? []
|
||||
const currentEffort = effortFor(effortKey)
|
||||
const isDefault =
|
||||
defaults?.[role] === current &&
|
||||
(effortOptions.length === 0 ||
|
||||
defaults?.[effortKey] === currentEffort)
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex min-w-0 flex-col gap-5 rounded-xl bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1.5 min-h-[2lh] text-[12px] font-medium leading-[1.55] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{help}
|
||||
</p>
|
||||
</div>
|
||||
{isDefault ? (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-full border border-white/[0.08] px-2 py-0.5 text-[10px] font-medium text-[#737B87]",
|
||||
)}
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Model</span>
|
||||
<Select
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({ ...d, [role]: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={controlClass}>
|
||||
<SelectValue placeholder="Select a model…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={selectContentClass}>
|
||||
{options.map((id) => (
|
||||
<SelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
className={selectItemClass}
|
||||
>
|
||||
{labelFor(id)}
|
||||
{MODEL_TAGS[id] ? (
|
||||
<span className="text-[#737B87]">
|
||||
{" "}
|
||||
· {MODEL_TAGS[id]}
|
||||
</span>
|
||||
) : null}
|
||||
{defaults?.[role] === id ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{effortOptions.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={fieldLabel}>Thinking depth</span>
|
||||
<div className="flex w-full items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
|
||||
{effortOptions.map((effort) => {
|
||||
const isOn = currentEffort === effort
|
||||
return (
|
||||
<button
|
||||
key={effort}
|
||||
type="button"
|
||||
aria-pressed={isOn}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
[effortKey]: effort,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-7 min-w-0 flex-1 cursor-pointer rounded-full px-1 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isOn
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#8B929E] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{EFFORT_LABELS[effort]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-1.5 text-[10px] font-medium text-[#4A5260]">
|
||||
<span>Faster</span>
|
||||
<span>Smarter</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] leading-[1.5] text-[#5F6673]",
|
||||
)}
|
||||
>
|
||||
{effortHelp}
|
||||
</p>
|
||||
{currentEffort === "xhigh" &&
|
||||
extraHighIsBounded(current) ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] text-amber-300/80",
|
||||
)}
|
||||
>
|
||||
Extra high maps to High for this model provider.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<Lock className="size-3.5" />
|
||||
Only organization admins can change these.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{dirty ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({})}
|
||||
disabled={update.isPending}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 rounded-full px-3 text-[13px] font-medium text-[#8B929E] transition-colors hover:text-[#FAFAFA] disabled:opacity-45",
|
||||
)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
<PillButton
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => {
|
||||
const patch: Partial<BrainModelConfig> = {}
|
||||
for (const { role, effortKey } of ROWS) {
|
||||
if (draft[role] && draft[role] !== resolved?.[role]) {
|
||||
patch[role] = draft[role]
|
||||
}
|
||||
if (
|
||||
draft[effortKey] &&
|
||||
draft[effortKey] !== resolved?.[effortKey]
|
||||
) {
|
||||
patch[effortKey] = draft[effortKey]
|
||||
}
|
||||
}
|
||||
update.mutate(patch, { onSuccess: () => setDraft({}) })
|
||||
}}
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
29
apps/web/components/settings/proactiveness-icon.tsx
Normal file
29
apps/web/components/settings/proactiveness-icon.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export function ProactivenessIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
26
apps/web/components/settings/proactiveness.tsx
Normal file
26
apps/web/components/settings/proactiveness.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import CompanyBrainAutomations from "./company-brain-automations"
|
||||
|
||||
export default function Proactiveness() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
if (!isCompanyBrain) {
|
||||
return (
|
||||
<div className="px-1 pt-2">
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
|
||||
Company Brain isn't enabled for this organization.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CompanyBrainAutomations />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,13 +10,13 @@ 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 CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import {
|
||||
LogOut,
|
||||
RotateCcw,
|
||||
|
|
@ -51,7 +51,6 @@ export const TABS = [
|
|||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -88,12 +87,6 @@ const NAV_ITEMS: NavItem[] = [
|
|||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "Connect apps to your brain — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
|
|
@ -154,6 +147,14 @@ export function SettingsContent({
|
|||
showIdentity?: boolean
|
||||
}) {
|
||||
const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
|
||||
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
// Company Brain orgs manage tools in the Configure view; hide the generic tabs.
|
||||
const navItems = isCompanyBrain
|
||||
? NAV_ITEMS.filter(
|
||||
(item) => item.id !== "integrations" && item.id !== "connections",
|
||||
)
|
||||
: NAV_ITEMS
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
|
@ -307,7 +308,7 @@ export function SettingsContent({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{navItems.map((item) => {
|
||||
const isExternal = item.id === "integrations"
|
||||
const isActive = !isExternal && activeTab === item.id
|
||||
return (
|
||||
|
|
@ -480,7 +481,6 @@ export function SettingsContent({
|
|||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
|||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
|
@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
|
|||
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
||||
if (doc.source === "mcp")
|
||||
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
||||
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
||||
if (isYouTubeUrl(doc.url))
|
||||
return {
|
||||
label: "YouTube Videos",
|
||||
singularLabel: "YouTube Video",
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import { useRouter } from "next/navigation"
|
|||
import {
|
||||
LogOut,
|
||||
Settings,
|
||||
Settings2,
|
||||
RotateCcw,
|
||||
HelpCircle,
|
||||
LifeBuoy,
|
||||
Building2,
|
||||
Sun,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -164,13 +164,15 @@ export function UserProfileMenu({
|
|||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("company-brain")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
Company Brain
|
||||
</DropdownMenuItem>
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("configure")}
|
||||
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"
|
||||
>
|
||||
<Settings2 className="size-4 text-[#737373]" />
|
||||
Configure
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("integrations")}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { isYouTubeUrl } from "@/lib/url-helpers"
|
||||
|
||||
export function isYouTubeUrl(url: string | undefined | null): boolean {
|
||||
if (!url) return false
|
||||
return (
|
||||
url.includes("youtube.com") ||
|
||||
url.includes("youtu.be") ||
|
||||
url.includes("m.youtube.com")
|
||||
)
|
||||
}
|
||||
export { isYouTubeUrl }
|
||||
|
||||
export function extractYouTubeVideoId(
|
||||
url: string | undefined | null,
|
||||
|
|
|
|||
68
apps/web/hooks/use-brain-models.ts
Normal file
68
apps/web/hooks/use-brain-models.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/models`
|
||||
|
||||
export type BrainModelRole = "main" | "triage" | "research"
|
||||
export type BrainReasoningEffort = "low" | "medium" | "high" | "xhigh"
|
||||
export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort"
|
||||
|
||||
export type BrainModelConfig = Record<BrainModelRole, string> &
|
||||
Partial<Record<BrainReasoningKey, BrainReasoningEffort>>
|
||||
|
||||
export type BrainModelsResponse = {
|
||||
resolved: BrainModelConfig
|
||||
defaults: BrainModelConfig
|
||||
choices: Record<BrainModelRole, string[]> &
|
||||
Partial<Record<BrainReasoningKey, BrainReasoningEffort[]>>
|
||||
}
|
||||
|
||||
export function useBrainModels(enabled: boolean) {
|
||||
const { org } = useAuth()
|
||||
return useQuery({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
queryFn: async (): Promise<BrainModelsResponse> => {
|
||||
const res = await fetch(`${BASE}/`, { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to load models")
|
||||
return res.json()
|
||||
},
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBrainModels() {
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (patch: Partial<BrainModelConfig>) => {
|
||||
const res = await fetch(`${BASE}/`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (res.status === 403)
|
||||
throw new Error("Only admins can change brain models.")
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
throw new Error(b.message ?? b.error ?? "Failed to save models")
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["brain", "models", org?.id],
|
||||
})
|
||||
toast.success("Brain models saved")
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save models"),
|
||||
})
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { $fetch } from "@lib/api"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
|
||||
import { getBackendUrl } from "@/lib/url-helpers"
|
||||
|
||||
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
|
||||
function fetchErrorMessage(err: unknown, fallback: string): string {
|
||||
|
|
@ -389,6 +390,7 @@ export function useDocumentMutations({
|
|||
urls: string[]
|
||||
project: string
|
||||
}): Promise<{ success: number; failed: number }> => {
|
||||
const entityContext = await resolveEntityContext(project)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
|
||||
|
|
@ -399,7 +401,7 @@ export function useDocumentMutations({
|
|||
documents: chunk.map((url) => ({
|
||||
content: url,
|
||||
containerTags: [project],
|
||||
entityContext,
|
||||
...(entityContext !== undefined ? { entityContext } : {}),
|
||||
metadata: { sm_source: "consumer" },
|
||||
})),
|
||||
},
|
||||
|
|
@ -500,14 +502,11 @@ export function useDocumentMutations({
|
|||
}
|
||||
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
},
|
||||
)
|
||||
const response = await fetch(`${getBackendUrl()}/v3/documents/file`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "Failed to upload file"
|
||||
|
|
|
|||
19
apps/web/hooks/use-org-member-role.ts
Normal file
19
apps/web/hooks/use-org-member-role.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
// Shared active-member role for the current org. Single queryKey so the
|
||||
// company-brain settings sections dedupe the getActiveMember call.
|
||||
export function useOrgMemberRole(enabled = true) {
|
||||
const { org } = useAuth()
|
||||
const query = useQuery({
|
||||
queryKey: ["org", "member-role", org?.id],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: enabled && !!org?.id,
|
||||
})
|
||||
const role = (query.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
return { role, isAdmin, query }
|
||||
}
|
||||
|
|
@ -37,12 +37,20 @@ export function useProcessingDocuments() {
|
|||
staleTime: 0,
|
||||
})
|
||||
|
||||
const docs =
|
||||
(
|
||||
data as
|
||||
| { documents?: Array<{ id?: string | null; status?: string | null }> }
|
||||
| undefined
|
||||
)?.documents ?? []
|
||||
// Memoized on `data` (kept referentially stable between polls by React
|
||||
// Query's structural sharing) so `processingMap` only changes identity
|
||||
// when the poll payload actually changes — the effect below depends on it.
|
||||
const docs = useMemo(
|
||||
() =>
|
||||
(
|
||||
data as
|
||||
| {
|
||||
documents?: Array<{ id?: string | null; status?: string | null }>
|
||||
}
|
||||
| undefined
|
||||
)?.documents ?? [],
|
||||
[data],
|
||||
)
|
||||
|
||||
const processingMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
|
|
@ -52,7 +60,6 @@ export function useProcessingDocuments() {
|
|||
}
|
||||
}
|
||||
return map
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docs])
|
||||
|
||||
// Detect docs that just finished (present in previous poll, absent now).
|
||||
|
|
@ -80,8 +87,10 @@ export function useProcessingDocuments() {
|
|||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processingMap.keys, queryClient.refetchQueries])
|
||||
// `processingMap` (not `processingMap.keys` — that's the shared
|
||||
// Map.prototype method, identical for every map, so the effect would
|
||||
// never re-run and finished docs would never trigger a refresh).
|
||||
}, [processingMap, queryClient])
|
||||
|
||||
return processingMap
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type ResearchEvent = {
|
|||
}
|
||||
|
||||
export type ResearchState = {
|
||||
status: "queued" | "running" | "done" | null
|
||||
status: "queued" | "running" | "done" | "error" | null
|
||||
domain: string | null
|
||||
findings: number
|
||||
events: ResearchEvent[]
|
||||
|
|
@ -54,7 +54,9 @@ export function useResearchStatus(enabled = true) {
|
|||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status
|
||||
const polls = query.state.dataUpdateCount
|
||||
if (status === "done" || polls >= MAX_POLLS) return false
|
||||
// error is terminal too — keep polling only while it can still progress.
|
||||
if (status === "done" || status === "error" || polls >= MAX_POLLS)
|
||||
return false
|
||||
return POLL_INTERVAL_MS
|
||||
},
|
||||
staleTime: 0,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ const TOKEN_METER_IDS = [
|
|||
] as const
|
||||
|
||||
export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
|
||||
const status = getSubscriptionStatus(autumn.data?.subscriptions)
|
||||
const subscriptions = autumn.data?.subscriptions
|
||||
const status = getSubscriptionStatus(subscriptions)
|
||||
|
||||
let currentPlan: PlanType = "free"
|
||||
if (isAllowedFrom(status, "api_enterprise")) {
|
||||
|
|
@ -54,6 +55,24 @@ export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
|
|||
|
||||
const hasPaidPlan = currentPlan !== "free"
|
||||
|
||||
const planProductId =
|
||||
currentPlan === "free" ? null : (`api_${currentPlan}` as const)
|
||||
const currentSub = planProductId
|
||||
? subscriptions?.find((s) => s.planId === planProductId)
|
||||
: undefined
|
||||
const isTrialing = currentSub?.status === "trialing"
|
||||
const trialEndsAtMs = (() => {
|
||||
if (!currentSub) return null
|
||||
const sub = currentSub as {
|
||||
trialEndsAt?: number | null
|
||||
currentPeriodEnd?: number | null
|
||||
expiresAt?: number | null
|
||||
}
|
||||
const raw = sub.trialEndsAt ?? sub.currentPeriodEnd ?? sub.expiresAt
|
||||
if (raw == null) return null
|
||||
return raw < 10_000_000_000 ? raw * 1000 : raw
|
||||
})()
|
||||
|
||||
const balances = autumn.data?.balances ?? {}
|
||||
|
||||
const tokensUsed = TOKEN_METER_IDS.reduce((sum, id) => {
|
||||
|
|
@ -88,6 +107,8 @@ export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
|
|||
planUsagePct,
|
||||
currentPlan,
|
||||
hasPaidPlan,
|
||||
isTrialing,
|
||||
trialEndsAtMs,
|
||||
isLoading,
|
||||
daysRemaining,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,13 +222,7 @@ export const analytics = {
|
|||
|
||||
// settings / spaces / docs analytics
|
||||
settingsTabChanged: (props: {
|
||||
tab:
|
||||
| "account"
|
||||
| "billing"
|
||||
| "integrations"
|
||||
| "connections"
|
||||
| "company-brain"
|
||||
| "support"
|
||||
tab: "account" | "billing" | "integrations" | "connections" | "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
||||
spaceCreated: () => safeCapture("space_created"),
|
||||
|
|
|
|||
|
|
@ -112,6 +112,78 @@ export function getBrainMode(
|
|||
: null
|
||||
}
|
||||
|
||||
export type BrainTrialStatus =
|
||||
| "active"
|
||||
| "exhausted"
|
||||
| "expired"
|
||||
| "converted"
|
||||
| "skipped"
|
||||
|
||||
export type BrainTrialInfo = {
|
||||
status: BrainTrialStatus | null
|
||||
startedAtMs: number | null
|
||||
endsAtMs: number | null
|
||||
credits: number | null
|
||||
daysRemaining: number | null
|
||||
}
|
||||
|
||||
function parseOrgMetadata(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!metadataRaw) return null
|
||||
if (typeof metadataRaw === "string") {
|
||||
try {
|
||||
return JSON.parse(metadataRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return metadataRaw
|
||||
}
|
||||
|
||||
/** Company Brain Slack trial fields written by mono after OAuth attach. */
|
||||
export function getBrainTrialInfo(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): BrainTrialInfo {
|
||||
const metadata = parseOrgMetadata(metadataRaw)
|
||||
const rawStatus = metadata?.brainTrialStatus
|
||||
const status =
|
||||
rawStatus === "active" ||
|
||||
rawStatus === "exhausted" ||
|
||||
rawStatus === "expired" ||
|
||||
rawStatus === "converted" ||
|
||||
rawStatus === "skipped"
|
||||
? rawStatus
|
||||
: null
|
||||
|
||||
const startedAtMs =
|
||||
typeof metadata?.brainTrialStartedAt === "string"
|
||||
? Date.parse(metadata.brainTrialStartedAt)
|
||||
: Number.NaN
|
||||
const endsAtMs =
|
||||
typeof metadata?.brainTrialEndsAt === "string"
|
||||
? Date.parse(metadata.brainTrialEndsAt)
|
||||
: Number.NaN
|
||||
const credits =
|
||||
typeof metadata?.brainTrialCredits === "number"
|
||||
? metadata.brainTrialCredits
|
||||
: null
|
||||
|
||||
const safeEnds = Number.isFinite(endsAtMs) ? endsAtMs : null
|
||||
const daysRemaining =
|
||||
safeEnds != null
|
||||
? Math.max(0, Math.ceil((safeEnds - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
: null
|
||||
|
||||
return {
|
||||
status,
|
||||
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null,
|
||||
endsAtMs: safeEnds,
|
||||
credits,
|
||||
daysRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffix for display
|
||||
* @example formatUsageNumber(1500000) => "1.5M"
|
||||
|
|
|
|||
69
apps/web/lib/extract-urls.test.ts
Normal file
69
apps/web/lib/extract-urls.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { extractUrls } from "./url-helpers"
|
||||
|
||||
describe("extractUrls", () => {
|
||||
it("extracts bare, markdown, and angle-bracket links", () => {
|
||||
const { urls } = extractUrls(
|
||||
"see https://a.example/one, [docs](https://b.example/two) and <https://c.example/three>",
|
||||
)
|
||||
expect(urls).toEqual([
|
||||
"https://a.example/one",
|
||||
"https://b.example/two",
|
||||
"https://c.example/three",
|
||||
])
|
||||
})
|
||||
|
||||
it("normalizes scheme-less URLs", () => {
|
||||
const { urls } = extractUrls("check supermemory.ai for details")
|
||||
expect(urls).toEqual(["https://supermemory.ai"])
|
||||
})
|
||||
|
||||
it("does not extract URLs from email addresses", () => {
|
||||
const result = extractUrls("email me at john.doe@example.com")
|
||||
expect(result.urls).toEqual([])
|
||||
expect(result.duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps real URLs while skipping emails in the same text", () => {
|
||||
const { urls } = extractUrls(
|
||||
"email john.doe@example.com or visit https://supermemory.ai",
|
||||
)
|
||||
expect(urls).toEqual(["https://supermemory.ai"])
|
||||
})
|
||||
|
||||
it("skips multiple email addresses", () => {
|
||||
const { urls } = extractUrls(
|
||||
"contacts: a.person@foo.example, b.person@bar.example",
|
||||
)
|
||||
expect(urls).toEqual([])
|
||||
})
|
||||
|
||||
it("strips trailing punctuation", () => {
|
||||
const { urls } = extractUrls("read https://example.com/post.")
|
||||
expect(urls).toEqual(["https://example.com/post"])
|
||||
})
|
||||
|
||||
it("dedupes URLs that differ only by scheme/host case or trailing slash", () => {
|
||||
const { urls, duplicates } = extractUrls(
|
||||
"HTTPS://EXAMPLE.COM/docs https://example.com/docs https://example.com/docs/",
|
||||
)
|
||||
expect(urls).toHaveLength(1)
|
||||
expect(duplicates).toBe(2)
|
||||
})
|
||||
|
||||
it("keeps URLs whose paths differ only by case", () => {
|
||||
const { urls, duplicates } = extractUrls(
|
||||
"https://example.com/Page and https://example.com/page",
|
||||
)
|
||||
expect(urls).toEqual([
|
||||
"https://example.com/Page",
|
||||
"https://example.com/page",
|
||||
])
|
||||
expect(duplicates).toBe(0)
|
||||
})
|
||||
|
||||
it("returns nothing for plain text", () => {
|
||||
expect(extractUrls("no links here").urls).toEqual([])
|
||||
expect(extractUrls("").urls).toEqual([])
|
||||
})
|
||||
})
|
||||
86
apps/web/lib/plugin-document.test.ts
Normal file
86
apps/web/lib/plugin-document.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { parsePluginDocument } from "./plugin-document"
|
||||
|
||||
type PluginDocumentInput = Parameters<typeof parsePluginDocument>[0]
|
||||
|
||||
function makeCodexSessionDocument(content: string): PluginDocumentInput {
|
||||
return {
|
||||
id: "doc_1",
|
||||
title: "Codex session",
|
||||
content,
|
||||
metadata: { sm_source: "codex" },
|
||||
memoryEntries: [],
|
||||
} as unknown as PluginDocumentInput
|
||||
}
|
||||
|
||||
describe("parsePluginDocument — session transcripts", () => {
|
||||
it("keeps multi-line message bodies intact", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
[
|
||||
"[Session abc-123]",
|
||||
"1. [user] Hello there",
|
||||
"Here is more context on line two",
|
||||
"2. [assistant] Sure!",
|
||||
"Second line of the reply",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed?.kind).toBe("codex-session")
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe(
|
||||
"Hello there\nHere is more context on line two",
|
||||
)
|
||||
expect(parsed?.messages[1]?.text).toBe("Sure!\nSecond line of the reply")
|
||||
})
|
||||
|
||||
it("surfaces memory id artifacts from continuation lines", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
[
|
||||
"[Session abc-123]",
|
||||
"1. [user] Remember my editor is Neovim",
|
||||
"memory id: mem_456",
|
||||
"2. [assistant] Saved it.",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages[0]?.text).toBe("Remember my editor is Neovim")
|
||||
expect(parsed?.artifacts).toContainEqual({
|
||||
label: "Memory ID",
|
||||
value: "mem_456",
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes literal \\n escapes before splitting messages", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
"[Session abc-123]\\n1. [user] First line\\nSecond line\\n2. [assistant] Reply",
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe("First line\nSecond line")
|
||||
expect(parsed?.messages[1]?.text).toBe("Reply")
|
||||
})
|
||||
|
||||
it("parses single-line messages as before", () => {
|
||||
const parsed = parsePluginDocument(
|
||||
makeCodexSessionDocument(
|
||||
["[Session abc-123]", "1. [user] Hi", "2. [assistant] Hello!"].join(
|
||||
"\n",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(parsed?.messages).toHaveLength(2)
|
||||
expect(parsed?.messages[0]?.text).toBe("Hi")
|
||||
expect(parsed?.messages[1]?.text).toBe("Hello!")
|
||||
expect(parsed?.summary).toBe(
|
||||
"1 user message and 1 assistant message captured from Codex.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -227,8 +227,11 @@ function parseTranscriptMessages(content: string): {
|
|||
} {
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const artifacts: PluginArtifact[] = []
|
||||
// The lookahead must end the last message at the true end of input:
|
||||
// with the m flag, a bare $ matches every line end and would cut each
|
||||
// message body off at its first newline.
|
||||
const regex = new RegExp(
|
||||
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|$)`,
|
||||
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|(?![\\s\\S]))`,
|
||||
"gm",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const viewLiterals = [
|
|||
"graph",
|
||||
"list",
|
||||
"integrations",
|
||||
"configure",
|
||||
"chat",
|
||||
"digests",
|
||||
// Integration sub-views — each card is its own view
|
||||
|
|
|
|||
73
apps/web/lib/url-helpers.test.ts
Normal file
73
apps/web/lib/url-helpers.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { isYouTubeUrl } from "./url-helpers"
|
||||
|
||||
describe("isYouTubeUrl", () => {
|
||||
it("matches canonical youtube.com watch URLs", () => {
|
||||
expect(isYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches real youtube subdomains", () => {
|
||||
expect(isYouTubeUrl("https://m.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches youtu.be short links", () => {
|
||||
expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches embed and shorts paths", () => {
|
||||
expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("is case-insensitive for scheme and host", () => {
|
||||
expect(isYouTubeUrl("HTTPS://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("https://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("matches scheme-less URLs", () => {
|
||||
expect(isYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
expect(isYouTubeUrl("www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects lookalike domains", () => {
|
||||
expect(isYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isYouTubeUrl("https://myyoutu.be/dQw4w9WgXcQ")).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects hosts that merely start with youtube.com", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://youtu.be.evil.example/dQw4w9WgXcQ")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects URLs that only contain youtube.com in the path", () => {
|
||||
expect(
|
||||
isYouTubeUrl("https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
).toBe(false)
|
||||
expect(isYouTubeUrl("https://evil.example/redirect?to=youtu.be/x")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects empty and nullish input", () => {
|
||||
expect(isYouTubeUrl("")).toBe(false)
|
||||
expect(isYouTubeUrl(null)).toBe(false)
|
||||
expect(isYouTubeUrl(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
const DEFAULT_BACKEND_URL = "https://api.supermemory.ai"
|
||||
const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai"
|
||||
const PROD_APP_ORIGIN = "https://app.supermemory.ai"
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
return (process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_BACKEND_URL).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
export function getAppOriginForCurrentEnvironment(hostname?: string): string {
|
||||
const currentHostname =
|
||||
hostname ?? (typeof window !== "undefined" ? window.location.hostname : "")
|
||||
|
|
@ -96,12 +104,20 @@ export const extractUrls = (
|
|||
const unwrapped = text
|
||||
.replace(MARKDOWN_LINK_REGEX, " $1 ")
|
||||
.replace(ANGLE_LINK_REGEX, " $1 ")
|
||||
const matches = unwrapped.match(URL_TOKEN_REGEX) ?? []
|
||||
const seen = new Set<string>()
|
||||
const urls: string[] = []
|
||||
let duplicates = 0
|
||||
for (const match of matches) {
|
||||
let trimmed = match.trim().replace(/[.,;!]+$/, "")
|
||||
for (const match of unwrapped.matchAll(URL_TOKEN_REGEX)) {
|
||||
const start = match.index ?? 0
|
||||
const end = start + match[0].length
|
||||
const before = start > 0 ? (unwrapped[start - 1] ?? "") : ""
|
||||
const after = end < unwrapped.length ? (unwrapped[end] ?? "") : ""
|
||||
// Skip email addresses: a domain-shaped token ending at "@" is the
|
||||
// local part, one starting right after "@" is the mail domain. Also
|
||||
// skip matches that begin mid-token (e.g. after "_", which the
|
||||
// hostname charset can't include) — those aren't standalone URLs.
|
||||
if (after === "@" || before === "@" || /[\w.-]/.test(before)) continue
|
||||
let trimmed = match[0].trim().replace(/[.,;!]+$/, "")
|
||||
const opens = (trimmed.match(/\(/g) ?? []).length
|
||||
const closes = (trimmed.match(/\)/g) ?? []).length
|
||||
if (closes > opens && trimmed.endsWith(")")) {
|
||||
|
|
@ -109,7 +125,15 @@ export const extractUrls = (
|
|||
}
|
||||
const normalized = normalizeUrl(trimmed)
|
||||
if (!isValidUrl(normalized)) continue
|
||||
const key = normalized.toLowerCase().replace(/\/+$/, "")
|
||||
// Dedupe on the parsed URL so the scheme and host compare
|
||||
// case-insensitively while the path/query — which are case-sensitive
|
||||
// resources — stay distinct.
|
||||
const parsed = new URL(normalized)
|
||||
const key =
|
||||
`${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`.replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
)
|
||||
if (seen.has(key)) {
|
||||
duplicates++
|
||||
continue
|
||||
|
|
@ -157,6 +181,22 @@ export const isTwitterUrl = (url: string): boolean => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a YouTube URL by matching the hostname against
|
||||
* youtube.com / youtu.be (and their subdomains, e.g. www / m).
|
||||
* Lookalike hosts (`notyoutube.com`, `youtube.com.evil.example`) and URLs
|
||||
* that only contain "youtube.com" in the path do not match.
|
||||
*/
|
||||
export const isYouTubeUrl = (url: string | undefined | null): boolean => {
|
||||
if (!url) return false
|
||||
const parsed = parseWebUrl(url)
|
||||
if (!parsed) return false
|
||||
return (
|
||||
hostnameMatches(parsed.hostname, "youtube.com") ||
|
||||
hostnameMatches(parsed.hostname, "youtu.be")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is a LinkedIn profile URL (not a company page).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { getSessionCookie } from "better-auth/cookies"
|
|||
import { NextResponse } from "next/server"
|
||||
import { getPublicRequestUrl } from "@/lib/url-helpers"
|
||||
|
||||
const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
|
||||
function getAuthSessionCookie(request: Request): string | null {
|
||||
return (
|
||||
getSessionCookie(request) ??
|
||||
|
|
@ -16,6 +18,18 @@ export default async function proxy(request: Request) {
|
|||
console.debug("[PROXY] Path:", url.pathname)
|
||||
console.debug("[PROXY] Method:", request.method)
|
||||
|
||||
// Development builds only: getPublicRequestUrl trusts x-forwarded-host, so
|
||||
// a hostname check alone could be spoofed in production to skip the /api
|
||||
// 401 gate below. NODE_ENV is inlined at build time, making this dead code
|
||||
// in production bundles.
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
LOCAL_DEV_HOSTS.has(url.hostname)
|
||||
) {
|
||||
console.debug("[PROXY] Local dev host, allowing access")
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sessionCookie = getAuthSessionCookie(request)
|
||||
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)
|
||||
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -320,7 +320,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ following the same pattern as the built-in Mem0 integration.
|
|||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from agent_framework import BaseContextProvider
|
||||
try:
|
||||
from agent_framework import BaseContextProvider
|
||||
except ImportError:
|
||||
# Renamed in agent-framework-core 1.0.0 stable; the interface is
|
||||
# unchanged (source_id __init__, before_run/after_run hooks with
|
||||
# identical keyword-only signatures).
|
||||
from agent_framework import ContextProvider as BaseContextProvider
|
||||
|
||||
from .connection import AgentSupermemory
|
||||
from .utils import (
|
||||
|
|
|
|||
|
|
@ -166,20 +166,26 @@ class SupermemoryCartesiaAgent:
|
|||
timeout=10.0
|
||||
)
|
||||
|
||||
static_count = len(response.profile.static) if response.profile.static else 0
|
||||
dynamic_count = len(response.profile.dynamic) if response.profile.dynamic else 0
|
||||
search_count = len(response.search_results.results) if response.search_results and response.search_results.results else 0
|
||||
|
||||
logger.info(f"[Supermemory] Retrieved memories - static: {static_count}, dynamic: {dynamic_count}, search: {search_count}")
|
||||
# A user with no stored memories yet gets a null profile back, which
|
||||
# is a normal case, not an error. Guard against it so we return an
|
||||
# empty profile instead of raising AttributeError on response.profile.
|
||||
profile = getattr(response, "profile", None)
|
||||
profile_static = profile.static if profile is not None and profile.static else []
|
||||
profile_dynamic = profile.dynamic if profile is not None and profile.dynamic else []
|
||||
|
||||
search_results = []
|
||||
if response.search_results and response.search_results.results:
|
||||
search_results = response.search_results.results
|
||||
|
||||
logger.info(
|
||||
f"[Supermemory] Retrieved memories - static: {len(profile_static)}, "
|
||||
f"dynamic: {len(profile_dynamic)}, search: {len(search_results)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"profile": {
|
||||
"static": response.profile.static or [],
|
||||
"dynamic": response.profile.dynamic or [],
|
||||
"static": profile_static,
|
||||
"dynamic": profile_dynamic,
|
||||
},
|
||||
"search_results": search_results,
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue