mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge origin/main into fix/openai-sdk-python-v4-api
Resolve pyproject.toml conflict by keeping supermemory>=3.50.0 (required for v4 API migration) and main's package version 1.0.5. Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
commit
f68bd30262
67 changed files with 1649 additions and 447 deletions
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
|
|||
memoryLength: memoryText.length,
|
||||
})
|
||||
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
acceptMemorySuggestion,
|
||||
clearMemorySuggestion,
|
||||
hasAcceptedSupermemoryContext,
|
||||
serializeMemoriesForDataset,
|
||||
setMemoryMarkerStatus,
|
||||
showLoadingSuggestion,
|
||||
showMarkerPopover,
|
||||
|
|
@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
|
|||
|
||||
if (response?.success && response?.data && input) {
|
||||
const memoryText = showMemorySuggestion("gemini", input, response.data)
|
||||
iconElement.dataset.memoriesData = String(response.data)
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
iconElement.dataset.supermemories = memoryText
|
||||
if (isAutoSearch) {
|
||||
setMemoryMarkerStatus(iconElement, "found")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,46 @@ export function buildSupermemoryText(memories: unknown): string {
|
|||
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
|
||||
}
|
||||
|
||||
function normalizeMemoryList(memories: unknown): string[] {
|
||||
const list = Array.isArray(memories)
|
||||
? memories
|
||||
: memories == null
|
||||
? []
|
||||
: [memories]
|
||||
return list
|
||||
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0)
|
||||
}
|
||||
|
||||
export function serializeMemoriesForDataset(memories: unknown): string {
|
||||
const list = normalizeMemoryList(memories)
|
||||
return list.length > 0 ? JSON.stringify(list) : ""
|
||||
}
|
||||
|
||||
export function parseMemoriesFromDataset(
|
||||
raw: string | null | undefined,
|
||||
): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
|
||||
} catch {
|
||||
// Not JSON — fall through to the legacy delimiter split.
|
||||
}
|
||||
return raw
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
}
|
||||
|
||||
export function renumberIncludedMemories(memories: string[]): string[] {
|
||||
return memories.map((memory, index) => {
|
||||
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
|
||||
return `${index + 1}. ${text} \n`
|
||||
})
|
||||
}
|
||||
|
||||
export function showMemorySuggestion(
|
||||
platform: string,
|
||||
input: SuggestionInput,
|
||||
|
|
@ -305,10 +345,7 @@ export function showMarkerPopover(
|
|||
color: rgba(255, 255, 255, 0.76);
|
||||
`
|
||||
|
||||
memories
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
parseMemoriesFromDataset(memories)
|
||||
.slice(0, 5)
|
||||
.forEach((memory) => {
|
||||
const item = document.createElement("div")
|
||||
|
|
|
|||
|
|
@ -10,11 +10,30 @@ import {
|
|||
autoCapturePromptsEnabled,
|
||||
} from "../../utils/storage"
|
||||
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
|
||||
import {
|
||||
buildSupermemoryText,
|
||||
parseMemoriesFromDataset,
|
||||
renumberIncludedMemories,
|
||||
serializeMemoriesForDataset,
|
||||
} from "./memory-suggestion"
|
||||
|
||||
let t3DebounceTimeout: NodeJS.Timeout | null = null
|
||||
let t3RouteObserver: MutationObserver | null = null
|
||||
let t3UrlCheckInterval: NodeJS.Timeout | null = null
|
||||
let t3ObserverThrottle: NodeJS.Timeout | null = null
|
||||
let t3IncludedPopup: {
|
||||
el: HTMLElement
|
||||
onClick: (event: MouseEvent) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} | null = null
|
||||
|
||||
function disposeT3IncludedPopup() {
|
||||
if (!t3IncludedPopup) return
|
||||
document.removeEventListener("click", t3IncludedPopup.onClick)
|
||||
clearTimeout(t3IncludedPopup.timer)
|
||||
t3IncludedPopup.el.remove()
|
||||
t3IncludedPopup = null
|
||||
}
|
||||
|
||||
export function initializeT3() {
|
||||
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
|
||||
|
|
@ -53,6 +72,7 @@ function setupT3RouteChangeDetection() {
|
|||
|
||||
const checkForRouteChange = () => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
disposeT3IncludedPopup()
|
||||
currentUrl = window.location.href
|
||||
setTimeout(() => {
|
||||
addSupermemoryIconToT3Input()
|
||||
|
|
@ -231,9 +251,13 @@ async function getRelatedMemoriesForT3(actionSource: string) {
|
|||
}
|
||||
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
|
||||
textareaElement.dataset.supermemories = buildSupermemoryText(
|
||||
response.data,
|
||||
)
|
||||
|
||||
iconElement.dataset.memoriesData = response.data
|
||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
||||
response.data,
|
||||
)
|
||||
|
||||
updateT3IconFeedback("Included Memories", iconElement)
|
||||
} else {
|
||||
|
|
@ -268,6 +292,8 @@ function updateT3IconFeedback(
|
|||
iconElement.dataset.originalHtml = iconElement.innerHTML
|
||||
}
|
||||
|
||||
disposeT3IncludedPopup()
|
||||
|
||||
const feedbackDiv = document.createElement("div")
|
||||
feedbackDiv.style.cssText = `
|
||||
display: flex;
|
||||
|
|
@ -329,11 +355,9 @@ function updateT3IconFeedback(
|
|||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const memoriesText = iconElement.dataset.memoriesData || ""
|
||||
const individualMemories = memoriesText
|
||||
.split(/[,\n]/)
|
||||
.map((memory) => memory.trim())
|
||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
||||
const individualMemories = parseMemoriesFromDataset(
|
||||
iconElement.dataset.memoriesData,
|
||||
)
|
||||
|
||||
individualMemories.forEach((memory, index) => {
|
||||
const memoryItem = document.createElement("div")
|
||||
|
|
@ -405,66 +429,65 @@ function updateT3IconFeedback(
|
|||
popup.style.display = "block"
|
||||
})
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.style.display = "none"
|
||||
}
|
||||
})
|
||||
}
|
||||
document.addEventListener("click", onClick)
|
||||
t3IncludedPopup = {
|
||||
el: popup,
|
||||
onClick,
|
||||
timer: setTimeout(disposeT3IncludedPopup, 300000),
|
||||
}
|
||||
|
||||
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
|
||||
htmlButton.parentElement?.remove()
|
||||
|
||||
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 remainingMemories = parseMemoriesFromDataset(
|
||||
iconElement.dataset.memoriesData,
|
||||
)
|
||||
remainingMemories.splice(index, 1)
|
||||
const remaining = renumberIncludedMemories(remainingMemories)
|
||||
|
||||
const textareaElement =
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
||||
|
||||
// Only wipe when nothing remains — `<= 1` used to discard the last kept memory.
|
||||
if (remaining.length === 0) {
|
||||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
delete iconElement.dataset.memoriesData
|
||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
||||
delete iconElement.dataset.originalHtml
|
||||
disposeT3IncludedPopup()
|
||||
return
|
||||
}
|
||||
|
||||
iconElement.dataset.memoriesData =
|
||||
serializeMemoriesForDataset(remaining)
|
||||
if (textareaElement) {
|
||||
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
|
||||
textareaElement.dataset.supermemories =
|
||||
buildSupermemoryText(remaining)
|
||||
}
|
||||
|
||||
content
|
||||
.querySelectorAll("button[data-memory-index]")
|
||||
.forEach((btn, newIndex) => {
|
||||
const htmlBtn = btn as HTMLButtonElement
|
||||
htmlBtn.dataset.memoryIndex = newIndex.toString()
|
||||
htmlBtn.dataset.memoryIndex = String(newIndex)
|
||||
const label = htmlBtn.previousElementSibling
|
||||
if (label) {
|
||||
label.textContent = remaining[newIndex].trim()
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
iconElement.innerHTML = ""
|
||||
|
|
@ -556,6 +579,7 @@ function setupT3PromptCapture() {
|
|||
if (textareaElement?.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
disposeT3IncludedPopup()
|
||||
}
|
||||
|
||||
const handleT3SendButtonClick = async (event: Event) => {
|
||||
|
|
@ -711,6 +735,7 @@ async function setupT3AutoFetch() {
|
|||
if (textareaElement.dataset.supermemories) {
|
||||
delete textareaElement.dataset.supermemories
|
||||
}
|
||||
disposeT3IncludedPopup()
|
||||
}
|
||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"check-types": "bun run compile",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,11 @@
|
|||
{
|
||||
"group": "Other resources",
|
||||
"pages": [
|
||||
{
|
||||
"group": "General",
|
||||
"icon": "book-open",
|
||||
"pages": ["ingestion/batch-ingest-historical-data"]
|
||||
},
|
||||
{
|
||||
"group": "Benchmarking",
|
||||
"icon": "flask-conical",
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ console.log(doc.status); // "queued" | "processing" | "done"
|
|||
|
||||
## Next Steps
|
||||
|
||||
- [How to backfill historical data](/ingestion/batch-ingest-historical-data) — Import dated content with the batch API
|
||||
- [Search Memories](/recall/search) — Query your content
|
||||
- [User Profiles](/recall/user-profiles) — Get user context
|
||||
- [Organizing & Filtering](/concepts/filtering) — Container tags and metadata
|
||||
|
|
|
|||
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal file
145
apps/docs/ingestion/batch-ingest-historical-data.mdx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
---
|
||||
title: "How to backfill historical data into Supermemory"
|
||||
sidebarTitle: "Backfill historical data"
|
||||
description: "Backfill historical documents into Supermemory with documentDate, stable custom IDs, and the batch ingestion API."
|
||||
icon: "history"
|
||||
---
|
||||
|
||||
Use `POST /v3/documents/batch` to backfill exports, emails, messages, or other dated records.
|
||||
|
||||
<Warning>
|
||||
Sort the source data oldest to newest, add `documentDate` to every document.
|
||||
</Warning>
|
||||
|
||||
## Backfill in batches
|
||||
|
||||
Backfill dated content by setting `documentDate` on each document, sorting the source records oldest to newest, and sending them in batches. Each request can contain up to 600 documents.
|
||||
|
||||
**Endpoint:** [`POST /v3/documents/batch`](/api-reference/ingest/batch-add-documents)
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript TypeScript
|
||||
import Supermemory from "supermemory";
|
||||
|
||||
type SourceDocument = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const client = new Supermemory();
|
||||
const batchSize = 100;
|
||||
|
||||
async function backfillHistoricalData(sourceDocuments: SourceDocument[]) {
|
||||
const documents = sourceDocuments
|
||||
.map((document) => ({
|
||||
content: document.content,
|
||||
customId: document.id,
|
||||
documentDate: new Date(document.createdAt).toISOString()
|
||||
}))
|
||||
.sort((a, b) => a.documentDate.localeCompare(b.documentDate));
|
||||
|
||||
for (let offset = 0; offset < documents.length; offset += batchSize) {
|
||||
const result = await client.documents.batchAdd({
|
||||
containerTag: "historical_import",
|
||||
documents: documents.slice(offset, offset + batchSize)
|
||||
});
|
||||
|
||||
if (result.failed > 0) {
|
||||
throw new Error(`${result.failed} documents failed to ingest`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python Python
|
||||
from datetime import datetime, timezone
|
||||
from supermemory import Supermemory
|
||||
|
||||
client = Supermemory()
|
||||
batch_size = 100
|
||||
|
||||
def to_utc(value: str) -> str:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("created_at must include a timezone")
|
||||
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def backfill_historical_data(source_documents: list[dict[str, str]]) -> None:
|
||||
documents = sorted(
|
||||
[
|
||||
{
|
||||
"content": document["content"],
|
||||
"custom_id": document["id"],
|
||||
"document_date": to_utc(document["created_at"]),
|
||||
}
|
||||
for document in source_documents
|
||||
],
|
||||
key=lambda document: document["document_date"],
|
||||
)
|
||||
|
||||
for offset in range(0, len(documents), batch_size):
|
||||
result = client.documents.batch_add(
|
||||
container_tag="historical_import",
|
||||
documents=documents[offset : offset + batch_size],
|
||||
)
|
||||
|
||||
if result.failed > 0:
|
||||
raise RuntimeError(f"{result.failed} documents failed to ingest")
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Optional: wait for processing to finish
|
||||
|
||||
**Endpoint:** [`GET /v3/documents/{id}`](/api-reference/documents/get-document)
|
||||
|
||||
The batch endpoint returns after accepting the documents. If a later step depends on completed memory generation, poll the returned document IDs until both `status` and `dreamingStatus` are `done`.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```typescript TypeScript
|
||||
async function waitUntilDone(ids: string[]) {
|
||||
while (true) {
|
||||
const documents = await Promise.all(
|
||||
ids.map((id) => client.documents.get(id))
|
||||
);
|
||||
|
||||
if (documents.some((document) => document.status === "failed")) {
|
||||
throw new Error("A document failed to process");
|
||||
}
|
||||
|
||||
if (
|
||||
documents.every(
|
||||
(document) =>
|
||||
document.status === "done" && document.dreamingStatus === "done"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python Python
|
||||
import time
|
||||
|
||||
def wait_until_done(ids: list[str]) -> None:
|
||||
while True:
|
||||
documents = [client.documents.get(document_id) for document_id in ids]
|
||||
|
||||
if any(document.status == "failed" for document in documents):
|
||||
raise RuntimeError("A document failed to process")
|
||||
|
||||
if all(
|
||||
document.status == "done" and document.dreaming_status == "done"
|
||||
for document in documents
|
||||
):
|
||||
return
|
||||
|
||||
time.sleep(10)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
|
@ -27,6 +27,28 @@ bunx supermemory local
|
|||
|
||||
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
|
||||
|
||||
### Pin or change versions
|
||||
|
||||
Pass an explicit version to install (or roll back to) a specific release instead of `latest`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://supermemory.ai/install | bash -s -- 0.0.3
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Before rolling back, back up your [data directory](#where-things-live). The installer replaces the binary, but an older server may not understand data or schema changes made by a newer release.
|
||||
</Warning>
|
||||
|
||||
Release tags are `server-v<version>` on [GitHub Releases](https://github.com/supermemoryai/supermemory/releases) (for example [`server-v0.0.3`](https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.3)).
|
||||
|
||||
To move to the newest release later:
|
||||
|
||||
```bash
|
||||
supermemory-server upgrade
|
||||
```
|
||||
|
||||
The binary may also print an “update available” notification on startup. If you intentionally pinned an older version (for example while debugging a regression), you can ignore that message until you are ready to upgrade.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi
|
|||
|
||||
<JourneyStep number="2" title="Ingest — get content in">
|
||||
<JourneyItem icon="plus" title="Add memories & documents" href="/ingestion/add-memories" />
|
||||
<JourneyItem icon="history" title="Backfill historical data" href="/ingestion/batch-ingest-historical-data" />
|
||||
<JourneyItem icon="files" title="Document operations" href="/ingestion/document-operations" />
|
||||
<JourneyItem icon="plug" title="Connectors" href="/connectors/overview" />
|
||||
<JourneyItem icon="file-stack" title="Content types" href="/concepts/content-types" />
|
||||
|
|
|
|||
|
|
@ -149,6 +149,19 @@ function objectProperty(value: unknown, key: string): unknown {
|
|||
: undefined
|
||||
}
|
||||
|
||||
// API error bodies are JSON like {"error": "..."} — unwrap them so users see
|
||||
// the real reason instead of raw JSON or a generic fallback.
|
||||
function extractApiErrorMessage(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string" || !raw) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown }
|
||||
if (typeof parsed.error === "string" && parsed.error) return parsed.error
|
||||
if (typeof parsed.message === "string" && parsed.message)
|
||||
return parsed.message
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
|
||||
export class SupermemoryClient {
|
||||
private client: Supermemory
|
||||
private containerTag: string
|
||||
|
|
@ -371,7 +384,8 @@ export class SupermemoryClient {
|
|||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
const message = extractApiErrorMessage(await response.text())
|
||||
throw Object.assign(new Error(message ?? ""), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
|
@ -432,11 +446,10 @@ export class SupermemoryClient {
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw Object.assign(
|
||||
new Error(message || "Failed to fetch memory entries"),
|
||||
{ status: response.status },
|
||||
)
|
||||
const message = extractApiErrorMessage(await response.text())
|
||||
throw Object.assign(new Error(message ?? ""), {
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
return memoryEntriesResponseSchema.parse(await response.json())
|
||||
|
|
@ -466,8 +479,7 @@ export class SupermemoryClient {
|
|||
|
||||
const status = objectProperty(error, "status")
|
||||
if (typeof status === "number") {
|
||||
const rawMessage = objectProperty(error, "message")
|
||||
const message = typeof rawMessage === "string" ? rawMessage : undefined
|
||||
const message = extractApiErrorMessage(objectProperty(error, "message"))
|
||||
switch (status) {
|
||||
case 400:
|
||||
case 422:
|
||||
|
|
@ -479,7 +491,7 @@ export class SupermemoryClient {
|
|||
case 403:
|
||||
throw new Error(
|
||||
message ||
|
||||
"Access forbidden. Your account may be restricted or blocked.",
|
||||
"Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status.",
|
||||
)
|
||||
case 404:
|
||||
throw new Error("Not found.")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "next dev --port ${PORT:-3004}",
|
||||
"build": "next build",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
EXA_API_KEY=
|
||||
XAI_API_KEY=
|
||||
XAI_API_KEY=
|
||||
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import { EnsureWorkspace } from "@/components/ensure-workspace"
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
|
||||
import { SettingsModalProvider } from "@/components/settings/settings-modal"
|
||||
import { PromoCodeHost } from "@/hooks/use-promo-code"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<PromoCodeHost />
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<PWAInstallPrompt />
|
||||
</SettingsModalProvider>
|
||||
|
|
|
|||
|
|
@ -591,6 +591,79 @@ export default function LoginPage() {
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
|
||||
process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? (
|
||||
<div className="w-full">
|
||||
<LastUsedBadge show={lastUsedMethod === "agentid"} />
|
||||
<ExternalAuthButton
|
||||
authIcon={
|
||||
<svg
|
||||
className="size-4 sm:size-5 text-foreground"
|
||||
fill="none"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>AgentID</title>
|
||||
<rect
|
||||
height="11"
|
||||
rx="2.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
width="14"
|
||||
x="5"
|
||||
y="8.21"
|
||||
/>
|
||||
<path
|
||||
d="M12 8.21V4.71M12 4.71a1.5 1.5 0 1 0-.01-3 1.5 1.5 0 0 0 .01 3Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<circle
|
||||
cx="9.25"
|
||||
cy="13.21"
|
||||
fill="currentColor"
|
||||
r="1.25"
|
||||
/>
|
||||
<circle
|
||||
cx="14.75"
|
||||
cy="13.21"
|
||||
fill="currentColor"
|
||||
r="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M9 16.21h6"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
authProvider="AgentID"
|
||||
className="w-full"
|
||||
disabled={Boolean(loadingMessage)}
|
||||
onClick={() => {
|
||||
if (loadingMessage) return
|
||||
setIsLoading(true)
|
||||
posthog.capture("login_attempt", {
|
||||
method: "social",
|
||||
provider: "agentid",
|
||||
})
|
||||
setPendingLoginMethod("agentid")
|
||||
signIn
|
||||
.oauth2({
|
||||
callbackURL: getCallbackURL(),
|
||||
providerId: "agentid",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TextSeparator
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context"
|
|||
import { useSession } from "@lib/auth"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { ArrowRight, Loader, XCircle } from "lucide-react"
|
||||
import { ArrowRight, XCircle } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
|
||||
|
||||
|
|
@ -88,7 +87,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
"Auto-capture of project decisions",
|
||||
"Context-aware suggestions",
|
||||
],
|
||||
icon: "/images/plugins/cursor.svg",
|
||||
icon: "/images/plugins/cursor.png",
|
||||
},
|
||||
codex: {
|
||||
name: "OpenAI Codex",
|
||||
|
|
@ -103,11 +102,77 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
|
|||
},
|
||||
}
|
||||
|
||||
const MULTI_PLUGIN_FEATURES = [
|
||||
"Share one persistent memory layer across selected coding agents.",
|
||||
"Recall project context, coding decisions, and prior sessions.",
|
||||
"Connect every selected plugin with one approval.",
|
||||
]
|
||||
|
||||
function isKnownPlugin(value: string): boolean {
|
||||
return Object.hasOwn(PLUGIN_INFO, value)
|
||||
}
|
||||
|
||||
function getPluginName(client: string): string {
|
||||
return PLUGIN_INFO[client]?.name ?? "External Tool"
|
||||
}
|
||||
|
||||
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
|
||||
function formatPluginNames(clients: string[]): string {
|
||||
const names = clients.map((id) => getPluginName(id))
|
||||
if (names.length === 0) return "External Tool"
|
||||
if (names.length === 1) return names[0] ?? "External Tool"
|
||||
if (names.length === 2) {
|
||||
return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}`
|
||||
}
|
||||
|
||||
return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}`
|
||||
}
|
||||
|
||||
function encodeBase64UrlJson(value: Record<string, string>): string {
|
||||
return btoa(JSON.stringify(value))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "")
|
||||
}
|
||||
|
||||
function PluginLogoStack({ clients }: { clients: string[] }) {
|
||||
if (clients.length === 0) {
|
||||
return (
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{clients.map((id, index) => {
|
||||
const plugin = PLUGIN_INFO[id]
|
||||
return (
|
||||
<div
|
||||
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
|
||||
key={`${id}-${index}`}
|
||||
style={{ zIndex: clients.length - index }}
|
||||
title={plugin?.name ?? id}
|
||||
>
|
||||
{plugin ? (
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-6 object-contain"
|
||||
height={24}
|
||||
src={plugin.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Status = "loading" | "creating" | "success" | "error"
|
||||
|
||||
const pageWrapperClass =
|
||||
"flex items-center justify-center min-h-screen bg-background p-4"
|
||||
|
|
@ -121,16 +186,34 @@ function AuthConnectContent() {
|
|||
const router = useRouter()
|
||||
const { data: session, isPending } = useSession()
|
||||
const { org, organizations, isRestoring } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const [status, setStatus] = useState<Status>("loading")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
|
||||
const callback = params.get("callback")
|
||||
const client = params.get("client")
|
||||
const validClient = client && client in PLUGIN_INFO ? client : null
|
||||
const displayName = validClient ? getPluginName(validClient) : "External Tool"
|
||||
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
|
||||
const clientsParam = params.get("clients")
|
||||
const hasClientList = params.has("clients")
|
||||
const rawRequestedClients = useMemo(
|
||||
() =>
|
||||
(clientsParam !== null ? clientsParam.split(",") : client ? [client] : [])
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
[client, clientsParam],
|
||||
)
|
||||
const requestedClients = useMemo(
|
||||
() => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))),
|
||||
[rawRequestedClients],
|
||||
)
|
||||
const invalidClients = useMemo(
|
||||
() => rawRequestedClients.filter((value) => !isKnownPlugin(value)),
|
||||
[rawRequestedClients],
|
||||
)
|
||||
const validClient = requestedClients[0] ?? null
|
||||
const displayName = formatPluginNames(requestedClients)
|
||||
const pluginInfo =
|
||||
requestedClients.length === 1 && validClient
|
||||
? PLUGIN_INFO[validClient]
|
||||
: null
|
||||
|
||||
// Redirect new users (logged in but no organization) to onboarding.
|
||||
// Store the current connect URL so onboarding can redirect back here.
|
||||
|
|
@ -166,6 +249,16 @@ function AuthConnectContent() {
|
|||
setError("Invalid callback URL.")
|
||||
return
|
||||
}
|
||||
if (invalidClients.length > 0) {
|
||||
setStatus("error")
|
||||
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
|
||||
return
|
||||
}
|
||||
if (requestedClients.length === 0) {
|
||||
setStatus("error")
|
||||
setError("Invalid or missing client.")
|
||||
return
|
||||
}
|
||||
if (!session || !org) {
|
||||
setStatus("error")
|
||||
setError(
|
||||
|
|
@ -177,17 +270,13 @@ function AuthConnectContent() {
|
|||
try {
|
||||
setStatus("creating")
|
||||
const fetchParams = new URLSearchParams({ callback })
|
||||
if (validClient) fetchParams.set("client", validClient)
|
||||
fetchParams.set("client", requestedClients[0] ?? "")
|
||||
|
||||
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
setStatus("upgrade")
|
||||
return
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -198,7 +287,21 @@ function AuthConnectContent() {
|
|||
setStatus("success")
|
||||
|
||||
const redirectUrl = new URL(callback)
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
if (hasClientList) {
|
||||
redirectUrl.searchParams.set(
|
||||
"keys",
|
||||
encodeBase64UrlJson(
|
||||
Object.fromEntries(
|
||||
requestedClients.map((requestedClient) => [
|
||||
requestedClient,
|
||||
data.key,
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
redirectUrl.searchParams.set("apikey", data.key)
|
||||
}
|
||||
redirectUrl.searchParams.set("api_url", API_URL)
|
||||
window.location.href = redirectUrl.toString()
|
||||
} catch (err) {
|
||||
|
|
@ -208,23 +311,23 @@ function AuthConnectContent() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleUpgrade() {
|
||||
try {
|
||||
setIsUpgrading(true)
|
||||
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
|
||||
await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: safeSuccessUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Upgrade failed:", err)
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Show a spinner while session/org data is loading or while we're about
|
||||
// to redirect to onboarding (prevents a brief flash of the connect card).
|
||||
const isAuthLoading = isPending || isRestoring || organizations === null
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "loading") return
|
||||
if (rawRequestedClients.length === 0) {
|
||||
setStatus("error")
|
||||
setError("Invalid or missing client.")
|
||||
return
|
||||
}
|
||||
if (invalidClients.length > 0) {
|
||||
setStatus("error")
|
||||
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
|
||||
}
|
||||
}, [invalidClients, rawRequestedClients.length, status])
|
||||
|
||||
if (isAuthLoading || shouldRedirectToOnboarding) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
|
|
@ -238,19 +341,7 @@ function AuthConnectContent() {
|
|||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
className="size-6"
|
||||
height={24}
|
||||
src={pluginInfo.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
<PluginLogoStack clients={requestedClients} />
|
||||
<div className="text-center">
|
||||
<h2
|
||||
className={dmSans125ClassName(
|
||||
|
|
@ -265,13 +356,15 @@ function AuthConnectContent() {
|
|||
)}
|
||||
>
|
||||
{pluginInfo?.description ??
|
||||
`Allow ${displayName} to access your Supermemory account.`}
|
||||
(requestedClients.length > 1
|
||||
? "Use one Supermemory account across these plugins."
|
||||
: `Use your Supermemory account with ${displayName}.`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginInfo && (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{pluginInfo.features.map((feature) => (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
|
||||
(feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
|
|
@ -282,16 +375,16 @@ function AuthConnectContent() {
|
|||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"text-[#FAFAFA] font-medium text-[14px]",
|
||||
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
|
|
@ -311,104 +404,6 @@ function AuthConnectContent() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "upgrade") {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
<div className={cardClass}>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
{pluginInfo ? (
|
||||
<Image
|
||||
alt={pluginInfo.name}
|
||||
className="size-6"
|
||||
height={24}
|
||||
src={pluginInfo.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : (
|
||||
<ArrowRight className="size-5 text-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2
|
||||
className={dmSans125ClassName(
|
||||
"font-semibold text-[18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{pluginInfo?.name ?? displayName}
|
||||
</h2>
|
||||
<p
|
||||
className={dmSans125ClassName(
|
||||
"text-[13px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
{pluginInfo?.description ??
|
||||
`A paid plan is required to use ${displayName} with Supermemory.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pluginInfo && (
|
||||
<ul className="w-full space-y-2.5">
|
||||
{pluginInfo.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5">
|
||||
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
className={dmSans125ClassName(
|
||||
"text-[13px] text-[#8B8B8B]",
|
||||
)}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading || autumn.isLoading}
|
||||
className={cn(
|
||||
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
|
||||
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
|
||||
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
"cursor-pointer transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
||||
boxShadow:
|
||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
|
||||
}}
|
||||
>
|
||||
{isUpgrading || autumn.isLoading ? (
|
||||
<>
|
||||
<Loader className="size-4 animate-spin mr-2" />
|
||||
Upgrading…
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Pro \u2014 $19/month"
|
||||
)}
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/settings#billing"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
>
|
||||
View all plans
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className={pageWrapperClass}>
|
||||
|
|
@ -435,7 +430,7 @@ function AuthConnectContent() {
|
|||
<div className="flex flex-col gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => void handleConnect()}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
|
||||
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Suspense } from "react"
|
|||
import { Toaster } from "@ui/components/sonner"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
import { ThemeProvider } from "@/lib/theme-provider"
|
||||
import { PromoCodeCapture } from "@/hooks/use-promo-code"
|
||||
|
||||
const font = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -95,6 +96,7 @@ export default function RootLayout({
|
|||
includeCredentials={true}
|
||||
headers={{ "X-App-Source": "nova" }}
|
||||
>
|
||||
<PromoCodeCapture />
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<PostHogProvider>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
getConnectionSubtitle,
|
||||
} from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type GDriveSyncScope = "scoped" | "full"
|
||||
|
||||
|
|
@ -309,6 +310,7 @@ interface ConnectContentProps {
|
|||
export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const { connectorAccess } = useConnectorAccess()
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
|
|
@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils"
|
|||
import { SpaceSelector } from "../space-selector"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type TabType = "note" | "link" | "file" | "connect"
|
||||
|
||||
|
|
@ -153,6 +154,7 @@ export function AddDocument({
|
|||
})
|
||||
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const {
|
||||
tokensUsed,
|
||||
searchesUsed,
|
||||
|
|
@ -342,8 +344,10 @@ export function AddDocument({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -442,8 +446,10 @@ export function AddDocument({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#account`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
|||
import { useQueryState } from "nuqs"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
||||
import { TrialSetupBanner } from "@/components/trial-setup-banner"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import {
|
||||
|
|
@ -170,6 +172,7 @@ export function BrainHomeView() {
|
|||
const o = useBrainOverview()
|
||||
const trial = useBrainTrial()
|
||||
const board = useConnectionsBoard()
|
||||
const { needsSetup } = useTrialStatus()
|
||||
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
||||
const milestones = [
|
||||
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
||||
|
|
@ -186,6 +189,7 @@ export function BrainHomeView() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||
<TrialSetupBanner />
|
||||
<StatsRow
|
||||
memories={o.memoriesCount}
|
||||
connected={o.connectedCount}
|
||||
|
|
@ -195,7 +199,7 @@ export function BrainHomeView() {
|
|||
setupTotal={milestonesTotal}
|
||||
lastUpdatedAt={o.lastUpdatedAt}
|
||||
/>
|
||||
{board.slack && !board.slack.connected && <SlackBanner />}
|
||||
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
{board.showBoard && <ConnectToolsCard board={board} />}
|
||||
|
|
@ -486,7 +490,7 @@ function BrainTimeline({
|
|||
canInvite: boolean
|
||||
toolsCardVisible: boolean
|
||||
}) {
|
||||
const trial = useBrainTrial()
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const { openSettings } = useSettingsModal()
|
||||
const { setViewMode } = useViewMode()
|
||||
const [, setInvite] = useQueryState("invite")
|
||||
|
|
@ -532,12 +536,13 @@ function BrainTimeline({
|
|||
title: slackConnected ? "Slack connected" : "Connect Slack",
|
||||
hint: slackConnected
|
||||
? undefined
|
||||
: trial.state === "trialing"
|
||||
? "Ask your brain from any channel."
|
||||
: "Starts your 14-day free trial. No credit card needed.",
|
||||
action: slackConnected
|
||||
? undefined
|
||||
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
||||
: needsSetup
|
||||
? "Starts with your trial."
|
||||
: "Ask your brain from any channel.",
|
||||
action:
|
||||
slackConnected || needsSetup
|
||||
? undefined
|
||||
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
||||
},
|
||||
...(rollout != null
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { cn } from "@lib/utils"
|
|||
import { ArrowRight, Loader2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
|
@ -192,6 +193,7 @@ export const CONNECT_TOOLS_CARD_ID = "connect-tools"
|
|||
export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { setViewMode } = useViewMode()
|
||||
const { loading, featured, overflow, busy, isConnected, connect } = board
|
||||
const { needsSetup } = useTrialStatus()
|
||||
|
||||
return (
|
||||
<section
|
||||
|
|
@ -209,11 +211,19 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
|||
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.
|
||||
{needsSetup
|
||||
? "Starts with your trial."
|
||||
: "Give your Slack agent live access to the apps your team already uses."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[12px] bg-[#14161A]",
|
||||
needsSetup && "pointer-events-none opacity-40 select-none",
|
||||
)}
|
||||
aria-disabled={needsSetup || undefined}
|
||||
>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
|
|
@ -248,6 +258,7 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
|
|||
|
||||
export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
|
||||
const { previewApps, isConnected, connectedCount } = board
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const prompts = previewApps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
.slice(0, 6)
|
||||
|
|
@ -275,12 +286,19 @@ export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
{needsSetup
|
||||
? "Starts with your trial."
|
||||
: connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[12px] bg-[#14161A]",
|
||||
needsSetup && "opacity-40 select-none",
|
||||
)}
|
||||
>
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.slug}
|
||||
|
|
@ -426,6 +444,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
|||
}
|
||||
|
||||
export function SlackBanner() {
|
||||
const { needsSetup } = useTrialStatus()
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
|
||||
|
|
@ -472,12 +491,20 @@ export function SlackBanner() {
|
|||
</div>
|
||||
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
href={
|
||||
needsSetup ? "/onboarding" : `${BACKEND}/brain/slack/oauth/install`
|
||||
}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
|
||||
>
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
{needsSetup ? (
|
||||
<span>Start trial</span>
|
||||
) : (
|
||||
<>
|
||||
<SlackMark className="hidden sm:block sm:size-[18px]" />
|
||||
<span className="sm:hidden">Add</span>
|
||||
<span className="hidden sm:inline">Add to Slack</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export function HomeChatComposer({
|
|||
const [attachmentDrafts, setAttachmentDrafts] = useState<
|
||||
ChatAttachmentDraft[]
|
||||
>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.5")
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
getDefaultReasoningEffort("grok-4.3"),
|
||||
getDefaultReasoningEffort("grok-4.5"),
|
||||
)
|
||||
const { selectedProject } = useProject()
|
||||
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
|
||||
|
|
|
|||
|
|
@ -206,11 +206,11 @@ export function ChatSidebar({
|
|||
>([])
|
||||
const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(
|
||||
initialSelectedModel ?? "grok-4.3",
|
||||
initialSelectedModel ?? "grok-4.5",
|
||||
)
|
||||
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||
initialReasoningEffort ??
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
|
||||
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"),
|
||||
)
|
||||
const selectedModelRef = useRef(selectedModel)
|
||||
selectedModelRef.current = selectedModel
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ export default function ChatModelSelector({
|
|||
minimal = false,
|
||||
dropdownDirection = "up",
|
||||
}: ChatModelSelectorProps = {}) {
|
||||
const [internalModel, setInternalModel] =
|
||||
useState<ModelId>("claude-sonnet-4.6")
|
||||
const [internalModel, setInternalModel] = useState<ModelId>("claude-sonnet-5")
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
LifeBuoy,
|
||||
LayoutGrid,
|
||||
MenuIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
Settings2,
|
||||
|
|
@ -55,6 +56,7 @@ const BACKEND =
|
|||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
|
||||
interface CompanyBrainHeaderProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +110,10 @@ function useSlackStatus() {
|
|||
})
|
||||
}
|
||||
|
||||
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
||||
export function CompanyBrainHeader({
|
||||
onAddMemory,
|
||||
onOpenSearch,
|
||||
}: CompanyBrainHeaderProps) {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan } = useTokenUsage(autumn)
|
||||
|
|
@ -412,6 +417,18 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
{onAddMemory && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={onAddMemory}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Plus className="size-4 text-[#737373]" />
|
||||
Add memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={goOverview}
|
||||
className={menuItemClass}
|
||||
|
|
@ -498,6 +515,29 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
|
|||
) : (
|
||||
<>
|
||||
<BrainTrialPill className="h-9 px-3" />
|
||||
{onAddMemory && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onAddMemory}
|
||||
aria-label="Add memory"
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Add</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Add memory (C)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canInvite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -45,42 +45,44 @@ export function CompanyBrainPromo() {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
|
||||
"relative flex items-start gap-3 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md sm:items-center sm:gap-4",
|
||||
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
|
||||
<div className="mt-1 flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef] sm:mt-0">
|
||||
<Logo className="h-4 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold text-[#fafafa]">
|
||||
Give your team a Company Brain
|
||||
</p>
|
||||
<p className="text-[13px] text-[#a1a1a1]">
|
||||
Lives in your Slack. Answers from your team's tools, and brings things
|
||||
up before you ask.
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:contents">
|
||||
<div className="min-w-0 pt-1 pr-7 sm:flex-1 sm:pt-0 sm:pr-0">
|
||||
<p className="text-[15px] font-semibold text-[#fafafa]">
|
||||
Give your team a Company Brain
|
||||
</p>
|
||||
<p className="text-[11px] leading-snug text-[#a1a1a1] sm:text-[13px] sm:leading-normal">
|
||||
Lives in your Slack. Answers from your team's tools, and brings
|
||||
things up before you ask.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9! min-h-9 w-fit shrink-0 self-end gap-1.5 rounded-full! px-3 font-medium sm:self-auto",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
variant="headers"
|
||||
>
|
||||
Set it up
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => {
|
||||
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
|
||||
router.push("/onboarding?new=1&mode=team")
|
||||
}}
|
||||
variant="headers"
|
||||
>
|
||||
Set it up
|
||||
<ArrowRight className="size-4 shrink-0" />
|
||||
</Button>
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
className="absolute right-2.5 top-2.5 shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa] sm:static"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -162,16 +162,19 @@ export function ConfigureView() {
|
|||
</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 className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="configure-section-actions" className="shrink-0" />
|
||||
</header>
|
||||
|
||||
<ErrorBoundary
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import { SlackConnectCard } from "@/components/slack-connect-card"
|
||||
import { TrialSetupBanner } from "@/components/trial-setup-banner"
|
||||
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||
|
|
@ -1344,6 +1345,7 @@ export function DashboardView({
|
|||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
||||
<TrialSetupBanner />
|
||||
<SlackConnectCard />
|
||||
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) =>
|
|||
export function Header(props: HeaderProps) {
|
||||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
if (hasCompanyBrain) {
|
||||
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
|
||||
return (
|
||||
<CompanyBrainHeader
|
||||
onAddMemory={props.onAddMemory}
|
||||
onOpenSearch={props.onOpenSearch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PersonalBrainHeader {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ export function HighlightsCard({
|
|||
if (isReplyOpen) replyInputRef.current?.focus()
|
||||
}, [isReplyOpen])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
|
||||
useEffect(() => {
|
||||
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
setIsExpanded(false)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import {
|
|||
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
|
||||
import { GranolaConnectModal } from "./granola-connect-modal"
|
||||
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
|
|
@ -536,13 +537,13 @@ const SECTIONS: Array<{
|
|||
action: { type: "external", href: POKE_RECIPE_URL },
|
||||
},
|
||||
{
|
||||
kind: "client",
|
||||
id: "shortcuts",
|
||||
name: "Apple Shortcuts",
|
||||
tagline: "Add memories from iPhone, iPad or Mac",
|
||||
simpleTitle: "Save anything from your phone or Mac",
|
||||
icon: <AppleShortcutsIcon />,
|
||||
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
||||
kind: "import",
|
||||
id: "x-bookmarks",
|
||||
name: "Import X bookmarks",
|
||||
tagline: "Turn your X/Twitter bookmarks into memories",
|
||||
simpleTitle: "Turn your X bookmarks into memory",
|
||||
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
||||
viewMode: "import" as ViewParamValue,
|
||||
},
|
||||
{
|
||||
kind: "client",
|
||||
|
|
@ -555,13 +556,13 @@ const SECTIONS: Array<{
|
|||
dev: true,
|
||||
},
|
||||
{
|
||||
kind: "import",
|
||||
id: "x-bookmarks",
|
||||
name: "Import X bookmarks",
|
||||
tagline: "Turn your X/Twitter bookmarks into memories",
|
||||
simpleTitle: "Turn your X bookmarks into memory",
|
||||
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
|
||||
viewMode: "import" as ViewParamValue,
|
||||
kind: "client",
|
||||
id: "shortcuts",
|
||||
name: "Apple Shortcuts",
|
||||
tagline: "Add memories from iPhone, iPad or Mac",
|
||||
simpleTitle: "Save anything from your phone or Mac",
|
||||
icon: <AppleShortcutsIcon />,
|
||||
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -2067,6 +2068,7 @@ function ItemCard({
|
|||
docsUrl,
|
||||
leftIndicator,
|
||||
statusSlot,
|
||||
layoutClassName,
|
||||
}: {
|
||||
actionSlot: ReactNode
|
||||
infoActionSlot?: ReactNode
|
||||
|
|
@ -2081,6 +2083,7 @@ function ItemCard({
|
|||
docsUrl?: string
|
||||
leftIndicator?: ReactNode
|
||||
statusSlot?: ReactNode
|
||||
layoutClassName?: string
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
return (
|
||||
|
|
@ -2098,6 +2101,9 @@ function ItemCard({
|
|||
className={cn(
|
||||
"group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center",
|
||||
layoutClassName,
|
||||
)}
|
||||
>
|
||||
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
|
||||
|
|
@ -2115,7 +2121,12 @@ function ItemCard({
|
|||
<div className="flex shrink-0 items-start justify-between gap-2">
|
||||
<IconBox>{icon}</IconBox>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3",
|
||||
id === "shortcuts" && "max-sm:contents",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{leftIndicator}
|
||||
|
|
@ -2139,7 +2150,13 @@ function ItemCard({
|
|||
{tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:col-span-2 max-sm:row-start-2 max-sm:w-full",
|
||||
)}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
|
||||
<div
|
||||
className="hidden min-w-0 flex-1 sm:flex"
|
||||
|
|
@ -2150,7 +2167,11 @@ function ItemCard({
|
|||
</div>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
|
||||
<div
|
||||
className="flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]"
|
||||
className={cn(
|
||||
"flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]",
|
||||
id === "shortcuts" &&
|
||||
"max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -2555,6 +2576,7 @@ export function IntegrationsView({
|
|||
const { allProjects } = useContainerTags()
|
||||
const shortcutsConnect = useShortcutsConnect()
|
||||
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
|
||||
const promoCode = usePromoCode()
|
||||
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
|
||||
// stay on hasProProduct. See useConnectorAccess.
|
||||
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
|
||||
|
|
@ -2747,11 +2769,6 @@ export function IntegrationsView({
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -2830,8 +2847,10 @@ export function IntegrationsView({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: checkoutPlanId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -2842,7 +2861,7 @@ export function IntegrationsView({
|
|||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
},
|
||||
[autumn],
|
||||
[autumn, promoCode],
|
||||
)
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
|
|
@ -3629,7 +3648,7 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const renderItemCard = (item: Item) => (
|
||||
const renderItemCard = (item: Item, layoutClassName?: string) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
actionSlot={renderRight(item)}
|
||||
|
|
@ -3645,6 +3664,7 @@ export function IntegrationsView({
|
|||
docsUrl={item.docsUrl}
|
||||
leftIndicator={renderLeftIndicator(item)}
|
||||
statusSlot={renderStatus(item)}
|
||||
layoutClassName={layoutClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -3754,7 +3774,14 @@ export function IntegrationsView({
|
|||
</p>
|
||||
) : q || category !== "all" ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleItems.map((item) => renderItemCard(item))}
|
||||
{visibleItems.map((item) =>
|
||||
renderItemCard(
|
||||
item,
|
||||
item.id === "shortcuts"
|
||||
? "sm:w-max sm:min-w-full"
|
||||
: undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
interface ConnectedPlugin {
|
||||
id: string
|
||||
|
|
@ -415,48 +416,11 @@ function PluginRow({
|
|||
)
|
||||
}
|
||||
|
||||
type TierFilter = "all" | "pro" | "free"
|
||||
|
||||
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "free", label: "Free" },
|
||||
]
|
||||
|
||||
function TierFilterToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: TierFilter
|
||||
onChange: (value: TierFilter) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
|
||||
{TIER_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => onChange(filter.value)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"rounded-full px-3 h-7 text-[12px] font-medium transition-colors",
|
||||
value === filter.value
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsDetail() {
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
|
||||
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
|
|
@ -572,11 +536,6 @@ export function PluginsDetail() {
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
@ -613,8 +572,10 @@ export function PluginsDetail() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
@ -635,17 +596,12 @@ export function PluginsDetail() {
|
|||
)
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
const filtered = catalogRows.filter((id) => {
|
||||
if (tierFilter === "free") return isFreeTierPlugin(id)
|
||||
if (tierFilter === "pro") return !isFreeTierPlugin(id)
|
||||
return true
|
||||
})
|
||||
// Connected plugins float to the top (stable within each group).
|
||||
return [...filtered].sort(
|
||||
return [...catalogRows].sort(
|
||||
(a, b) =>
|
||||
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
|
||||
)
|
||||
}, [catalogRows, tierFilter, connectedPluginIds])
|
||||
}, [catalogRows, connectedPluginIds])
|
||||
|
||||
const dialogPlugin = newKey.pluginId
|
||||
? PLUGIN_CATALOG[newKey.pluginId]
|
||||
|
|
@ -684,12 +640,7 @@ export function PluginsDetail() {
|
|||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SectionHeader>Plugins</SectionHeader>
|
||||
{catalogRows.length > 0 && (
|
||||
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
|
||||
)}
|
||||
</div>
|
||||
<SectionHeader>Plugins</SectionHeader>
|
||||
<div className="flex flex-col">
|
||||
{visibleRows.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
|
|||
}) {
|
||||
const { connect, isPending, pendingType } = controller
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<PillButton
|
||||
className="h-9 flex-none"
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import {
|
|||
UserAvatar,
|
||||
} from "./step-about"
|
||||
import { ResearchActionRail } from "./research-action-rail"
|
||||
import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
type CompanyBrainConfirmResult,
|
||||
type CompanyBrainOrganizationChoice,
|
||||
|
|
@ -52,7 +55,7 @@ interface CompanyBrainOnboardingProps {
|
|||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type Phase = "confirm" | "research"
|
||||
type Phase = "confirm" | "trial" | "research"
|
||||
|
||||
function normalizeDomain(input: string): string {
|
||||
const host = input
|
||||
|
|
@ -82,6 +85,23 @@ export function CompanyBrainOnboarding({
|
|||
onUsePersonal,
|
||||
}: CompanyBrainOnboardingProps) {
|
||||
const [phase, setPhase] = useState<Phase>("confirm")
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const resumedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (resumedRef.current) return
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
|
||||
resumedRef.current = true
|
||||
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
|
||||
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
|
||||
setPhase("research")
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (resumedRef.current || !needsSetup || phase !== "confirm") return
|
||||
resumedRef.current = true
|
||||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}, [needsSetup, phase])
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [organizationChoices, setOrganizationChoices] = useState<
|
||||
CompanyBrainOrganizationChoice[] | null
|
||||
|
|
@ -107,7 +127,8 @@ export function CompanyBrainOnboarding({
|
|||
}
|
||||
setOrganizationChoices(null)
|
||||
setServerSchedulesResearch(result.serverSchedulesResearch)
|
||||
setPhase("research")
|
||||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}
|
||||
|
||||
// New-org signup schedules research after provisioning; if that hook is slow
|
||||
|
|
@ -203,9 +224,9 @@ export function CompanyBrainOnboarding({
|
|||
<main
|
||||
className={cn(
|
||||
"relative z-10 flex-1 flex flex-col min-h-0",
|
||||
phase === "confirm"
|
||||
? "justify-center items-center px-4 md:px-10"
|
||||
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
|
||||
phase === "research"
|
||||
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
|
||||
: "justify-center items-center px-4 md:px-10",
|
||||
)}
|
||||
>
|
||||
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
|
||||
|
|
@ -215,13 +236,25 @@ export function CompanyBrainOnboarding({
|
|||
style={cardSurfaceStyle}
|
||||
className={cn(
|
||||
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
|
||||
phase === "confirm"
|
||||
? "max-w-xl p-6 md:p-8"
|
||||
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
|
||||
phase === "research"
|
||||
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
|
||||
: phase === "trial"
|
||||
? "max-w-4xl p-6 md:p-7"
|
||||
: "max-w-xl p-6 md:p-8",
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{phase === "confirm" ? (
|
||||
{phase === "trial" ? (
|
||||
<motion.div
|
||||
key="trial"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<StepTrial onActive={() => setPhase("research")} />
|
||||
</motion.div>
|
||||
) : phase === "confirm" ? (
|
||||
<motion.div
|
||||
key="confirm"
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -525,6 +525,8 @@ function SlackStepBody({
|
|||
<div>
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
|
||||
dmSans125ClassName(),
|
||||
|
|
@ -534,7 +536,7 @@ function SlackStepBody({
|
|||
Add to Slack
|
||||
</a>
|
||||
<p className="mt-2 text-center text-[11px] font-medium leading-[1.5] text-[#525D6E]">
|
||||
Starts your 14-day free trial. No credit card needed.
|
||||
Included in your 14-day trial.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react"
|
|||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type SourceId =
|
||||
| "drive"
|
||||
|
|
@ -97,7 +98,7 @@ type SourceId =
|
|||
| "raycast"
|
||||
type SourceState = "idle" | "connecting" | "connected" | "waitlist"
|
||||
type DriveScope = "selective" | "full"
|
||||
type RequiredPlan = "pro" | "max"
|
||||
type RequiredPlan = "pro" | "max" | "scale"
|
||||
|
||||
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
|
||||
"google-drive": "drive",
|
||||
|
|
@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
|
|||
const PLAN_LABELS: Record<RequiredPlan, string> = {
|
||||
pro: "Pro",
|
||||
max: "Max",
|
||||
scale: "Scale",
|
||||
}
|
||||
|
||||
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
|
||||
|
|
@ -148,11 +150,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
credits: "$20",
|
||||
productId: "api_pro",
|
||||
description: "For people building with AI memory",
|
||||
features: [
|
||||
"Auto top-up when balance runs low",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Priority support",
|
||||
],
|
||||
features: ["Auto top-up when balance runs low", "Priority support"],
|
||||
},
|
||||
{
|
||||
id: "max",
|
||||
|
|
@ -277,7 +275,12 @@ export function StepSources({
|
|||
const [granolaOpen, setGranolaOpen] = useState(false)
|
||||
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
|
||||
const [requestedConnector, setRequestedConnector] = useState("This connector")
|
||||
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
|
||||
const {
|
||||
hasMax,
|
||||
hasScale,
|
||||
connectorAccess,
|
||||
loading: planLoading,
|
||||
} = useConnectorAccess()
|
||||
const { org, isRestoring } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -362,10 +365,12 @@ export function StepSources({
|
|||
}
|
||||
}, [connectedParam])
|
||||
|
||||
// company_brain unlocks pro connectors; max stays gated
|
||||
// company_brain unlocks pro connectors; max and scale stay gated, and a
|
||||
// higher tier satisfies a lower requirement.
|
||||
const isLocked = (plan?: RequiredPlan) => {
|
||||
if (!plan || planLoading) return false
|
||||
if (plan === "max") return !hasMax
|
||||
if (plan === "scale") return !hasScale
|
||||
if (plan === "max") return !(hasMax || hasScale)
|
||||
return !connectorAccess
|
||||
}
|
||||
|
||||
|
|
@ -610,6 +615,7 @@ function OnboardingPlansModal({
|
|||
requestedPlan: RequiredPlan
|
||||
}) {
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const { currentPlan, isLoading } = useTokenUsage(autumn)
|
||||
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
|
||||
null,
|
||||
|
|
@ -628,8 +634,10 @@ function OnboardingPlansModal({
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: window.location.href,
|
||||
})
|
||||
promoCode.clear()
|
||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||
return
|
||||
|
|
@ -1185,14 +1193,14 @@ function MoreSourcesGrid({
|
|||
icon={<Github className="size-6 text-[#fafafa]" />}
|
||||
state={values.connected.github ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
locked={isLocked("max")}
|
||||
requiredPlan="max"
|
||||
locked={isLocked("scale")}
|
||||
requiredPlan="scale"
|
||||
perks={[
|
||||
"PRs and issues parsed",
|
||||
"READMEs and docs indexed",
|
||||
"Stays in sync with new activity",
|
||||
]}
|
||||
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
|
||||
onConnect={guard("scale", "GitHub", () => requestWaitlist("github"))}
|
||||
/>
|
||||
{mode === "personal" ? (
|
||||
<GranolaSourceCard
|
||||
|
|
|
|||
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal file
239
apps/web/components/onboarding-brain/step-trial.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"use client"
|
||||
|
||||
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
|
||||
import { GradientLogo } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowRight, Loader2, ShieldCheck } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
export const CHECKOUT_RETURN_PARAM = "brainTrial"
|
||||
|
||||
const TRIAL_DAYS = 14
|
||||
/** The only reminder that lands before the charge; 15 and 17 are post-trial. */
|
||||
const REMINDER_DAY = 12
|
||||
const MONTHLY_PRICE = "$100"
|
||||
|
||||
function checkoutReturnUrl(): string {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function dayOffset(days: number): string {
|
||||
const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
||||
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
const ORBIT = [
|
||||
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
|
||||
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
|
||||
{ key: "notion", r: 74, deg: 236, node: <Notion className="size-4" /> },
|
||||
{ key: "drive", r: 112, deg: 58, node: <GoogleDrive className="size-4" /> },
|
||||
{ key: "granola", r: 112, deg: 172, node: <Granola className="size-4" /> },
|
||||
{ key: "mcp", r: 112, deg: 296, node: <MCPIcon className="size-4" /> },
|
||||
]
|
||||
|
||||
const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]"
|
||||
const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]"
|
||||
|
||||
function BrainPanel() {
|
||||
return (
|
||||
<div className="relative hidden aspect-[3/2] w-[56%] shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#0B0E13] ring-1 ring-white/[0.06] md:flex">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute size-44 rounded-full bg-[#4BA0FA]/15 blur-3xl"
|
||||
/>
|
||||
|
||||
<div aria-hidden="true" className="relative size-[248px]">
|
||||
<span className="absolute left-1/2 top-1/2 size-[148px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.07]" />
|
||||
<span className="absolute left-1/2 top-1/2 size-[224px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.05]" />
|
||||
|
||||
<div className={cn("absolute inset-0", SPIN)}>
|
||||
{ORBIT.map(({ key, r, deg, node }) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${r}px)`,
|
||||
}}
|
||||
className="absolute left-1/2 top-1/2 flex size-8 items-center justify-center rounded-full bg-[#161B22] ring-1 ring-white/10"
|
||||
>
|
||||
<span
|
||||
className={cn("flex", SPIN_BACK)}
|
||||
style={{ rotate: `${-deg}deg` }}
|
||||
>
|
||||
{node}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<GradientLogo className="absolute left-1/2 top-1/2 h-auto w-[68px] -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<p className="absolute inset-x-0 bottom-5 text-center text-[13px] font-medium text-[#8b8b8b]">
|
||||
Meet <span className="text-[#4BA0FA]">@supermemory</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRow({
|
||||
date,
|
||||
title,
|
||||
value,
|
||||
current,
|
||||
}: {
|
||||
date: string
|
||||
title: string
|
||||
value?: string
|
||||
current?: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="relative flex items-start gap-3 pl-[18px]">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-0 top-[5px] size-[7px] rounded-full",
|
||||
current
|
||||
? "bg-[#fafafa] ring-4 ring-[#fafafa]/10"
|
||||
: "bg-[#2b3138] ring-1 ring-white/15",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-[13px] font-medium text-[#fafafa]">{date}</span>
|
||||
<span className="text-[12px] leading-snug text-[#8b8b8b]">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{value ? (
|
||||
<span className="shrink-0 text-[14px] font-medium text-[#fafafa] tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepTrial({ onActive }: { onActive: () => void }) {
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
const start = async () => {
|
||||
if (starting) return
|
||||
setStarting(true)
|
||||
analytics.brainTrialCheckoutStarted()
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/trial/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ successUrl: checkoutReturnUrl() }),
|
||||
})
|
||||
const data = (await res.json()) as {
|
||||
checkoutUrl?: string | null
|
||||
status?: string
|
||||
error?: string
|
||||
}
|
||||
if (res.status === 409 || data.error === "trial_unavailable") {
|
||||
throw new Error(
|
||||
"This workspace has already used its free trial. Upgrade from billing to continue.",
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.")
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl
|
||||
return
|
||||
}
|
||||
if (data.status === "already_active" || data.status === "attached") {
|
||||
onActive()
|
||||
return
|
||||
}
|
||||
throw new Error("Couldn't start the trial.")
|
||||
} catch (error) {
|
||||
console.error("Failed to start trial:", error)
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Couldn't start the trial.",
|
||||
)
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[22px] leading-tight font-medium text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
Start your {TRIAL_DAYS}-day free trial
|
||||
</h2>
|
||||
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
|
||||
Add a payment method to start. You will not be charged today. We
|
||||
will email you before your first payment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="relative flex flex-col gap-5 py-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[3px] top-2.5 bottom-[22px] w-px bg-white/10"
|
||||
/>
|
||||
<TimelineRow
|
||||
date="Today"
|
||||
title="Full access to Company Brain"
|
||||
value="$0"
|
||||
current
|
||||
/>
|
||||
<TimelineRow
|
||||
date={dayOffset(REMINDER_DAY)}
|
||||
title="We email you before the charge"
|
||||
/>
|
||||
<TimelineRow
|
||||
date={dayOffset(TRIAL_DAYS)}
|
||||
title="Trial ends"
|
||||
value={`${MONTHLY_PRICE}/mo`}
|
||||
/>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={start}
|
||||
disabled={starting}
|
||||
className="w-full justify-center rounded-full px-5 py-[11px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{starting ? (
|
||||
<>
|
||||
Opening checkout…
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Start free trial
|
||||
<ArrowRight className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="flex items-center gap-1.5 text-[12px] text-[#737373]">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Secured by Stripe · Cancel in one click
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrainPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -394,11 +394,6 @@ export function SelectSpacesModal({
|
|||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"Plugin access was denied. Check your plan or try again.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
Copy,
|
||||
LoaderIcon,
|
||||
ChevronDown,
|
||||
Users,
|
||||
|
|
@ -458,10 +459,26 @@ export default function Account({
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
"flex items-center gap-1 text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Organization
|
||||
{org?.id ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy organization ID"
|
||||
title={org.id}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(org.id)
|
||||
.then(() => toast.success("Organization ID copied"))
|
||||
.catch(() => toast.error("Couldn't copy"))
|
||||
}}
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors hover:text-[#FAFAFA]"
|
||||
>
|
||||
<Copy className="size-2.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{isEditingOrgName ? (
|
||||
<form
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
|
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
features: [
|
||||
"Pay-as-you-go after $5 runs out",
|
||||
"Full search and memory access",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Email support",
|
||||
],
|
||||
},
|
||||
|
|
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
features: [
|
||||
"Auto top-up when balance runs low",
|
||||
"Google Drive, Notion, OneDrive & Granola connectors",
|
||||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
|
|
@ -203,8 +204,24 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
|
|||
},
|
||||
]
|
||||
|
||||
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
|
||||
// Company Brain workspaces sell Max / Scale / Enterprise (no Free, Pro).
|
||||
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
price: "$100",
|
||||
period: "/mo",
|
||||
credits: "$130",
|
||||
productId: "api_max",
|
||||
description: "Company Brain for teams with everyday usage",
|
||||
mostPopular: true,
|
||||
features: [
|
||||
"Company Brain Slack agent & shared memory",
|
||||
"$130 monthly usage credits",
|
||||
"Unlimited seats",
|
||||
"Auto top-up & spend caps",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
|
|
@ -212,13 +229,13 @@ const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
|||
period: "/mo",
|
||||
credits: "$600",
|
||||
productId: "api_scale",
|
||||
description: "Company Brain for your team, with production usage",
|
||||
mostPopular: true,
|
||||
description: "Company Brain for production workloads",
|
||||
includesFrom: "Max",
|
||||
features: [
|
||||
"Company Brain Slack agent & shared memory",
|
||||
"$600 monthly usage credits when paid",
|
||||
"Auto top-up & spend caps",
|
||||
"Team connectors & dedicated support",
|
||||
"$600 monthly usage credits",
|
||||
"GitHub, S3 & Web Crawler connectors",
|
||||
"Restricted access, container tags & User Insights",
|
||||
"Dedicated support",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -515,6 +532,7 @@ export default function Billing() {
|
|||
const queryClient = useQueryClient()
|
||||
const { user, org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const posthog = usePostHog()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const brainTrial = useMemo(
|
||||
|
|
@ -682,8 +700,10 @@ export default function Billing() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId,
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#billing`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||||
return
|
||||
|
|
@ -914,6 +934,27 @@ export default function Billing() {
|
|||
)
|
||||
}
|
||||
|
||||
// The trial runs on api_scale, so Max ranks below the current plan and would
|
||||
// otherwise render as a dead "Included with Scale" button. Trial users are
|
||||
// exactly who we want on Max, so it needs its own actionable path.
|
||||
if (plan.id === "max" && (isOnTrial || isBrainTrialEnded)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpgrade("api_max")}
|
||||
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 Max
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
|
||||
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
|
||||
return (
|
||||
|
|
@ -1471,6 +1512,20 @@ export default function Billing() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
<div className="md:col-span-2 space-y-2 rounded-lg border border-white/[0.08] bg-white/[0.02] p-4 text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{isOnTrial ? (
|
||||
<p>
|
||||
Your trial runs on Scale. Moving to Max keeps the agent,
|
||||
shared memory and unlimited seats, and drops the GitHub, S3
|
||||
and Web Crawler connectors, restricted access and container
|
||||
tags, and User Insights.
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
Using more than about $400 of credits a month? Scale works out
|
||||
cheaper than Max plus top-ups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
Radar,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -35,6 +36,8 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
|
||||
import { configureSectionToPath } from "@/lib/configure-routes"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
|
|
@ -301,6 +304,9 @@ function AutomationCard({
|
|||
id,
|
||||
channels,
|
||||
ownerLabel,
|
||||
personalOnlyApps = [],
|
||||
isAdmin = false,
|
||||
appCatalog = {},
|
||||
onDone,
|
||||
onCancelNew,
|
||||
onCollapse,
|
||||
|
|
@ -309,6 +315,9 @@ function AutomationCard({
|
|||
id: string | null
|
||||
channels: Channel[]
|
||||
ownerLabel?: string
|
||||
personalOnlyApps?: string[]
|
||||
isAdmin?: boolean
|
||||
appCatalog?: Record<string, { name: string; iconDomain?: string }>
|
||||
onDone: () => void
|
||||
onCancelNew?: () => void
|
||||
onCollapse?: () => void
|
||||
|
|
@ -349,9 +358,18 @@ function AutomationCard({
|
|||
const b = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(b.error ?? "Couldn't save.")
|
||||
}
|
||||
const b = (await res.json().catch(() => ({}))) as {
|
||||
warnings?: { app: string }[]
|
||||
}
|
||||
return b.warnings ?? []
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (warnings) => {
|
||||
toast.success("Automation saved.")
|
||||
if (warnings.length)
|
||||
toast.warning(
|
||||
`Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`,
|
||||
{ duration: 10000 },
|
||||
)
|
||||
onDone()
|
||||
},
|
||||
onError: (err) =>
|
||||
|
|
@ -619,6 +637,51 @@ function AutomationCard({
|
|||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{draft.deliverTo === "channel" && personalOnlyApps.length > 0 && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-[12px] leading-snug text-amber-300/80">
|
||||
<TooltipProvider>
|
||||
<span className="flex shrink-0 -space-x-1">
|
||||
{personalOnlyApps.map((app) => (
|
||||
<Tooltip key={app}>
|
||||
<TooltipTrigger asChild>
|
||||
{appCatalog[app]?.iconDomain ? (
|
||||
<img
|
||||
alt={appCatalog[app]?.name ?? app}
|
||||
className="size-4 cursor-default select-none rounded-full bg-white/10 ring-1 ring-black/50"
|
||||
src={`https://www.google.com/s2/favicons?domain=${appCatalog[app]?.iconDomain}&sz=32`}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-4 cursor-default select-none items-center justify-center rounded-full bg-amber-500/20 text-[9px] uppercase ring-1 ring-black/50">
|
||||
{app.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{appCatalog[app]?.name ?? app}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
</TooltipProvider>
|
||||
<span className="truncate">
|
||||
only connected to you ·{" "}
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-amber-200"
|
||||
href={configureSectionToPath("tools")}
|
||||
>
|
||||
Connect for workspace
|
||||
</a>{" "}
|
||||
to use here
|
||||
</>
|
||||
) : (
|
||||
"ask an admin to connect it for the workspace"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{id ? (
|
||||
|
|
@ -851,8 +914,14 @@ function PresetCard({
|
|||
export default function CompanyBrainAutomations() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const { user, org } = useAuth()
|
||||
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
|
||||
const queryClient = useQueryClient()
|
||||
const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
|
||||
const [showAllTemplates, setShowAllTemplates] = useState(false)
|
||||
const [actionSlot, setActionSlot] = useState<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
setActionSlot(document.getElementById("configure-section-actions"))
|
||||
}, [])
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const draftKey = useRef(0)
|
||||
const addDraft = (draft: Draft) =>
|
||||
|
|
@ -886,11 +955,15 @@ export default function CompanyBrainAutomations() {
|
|||
const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) return [] as string[]
|
||||
if (!res.ok) return [] as { serverSlug: string; userId: string | null }[]
|
||||
const body = (await res.json()) as {
|
||||
connections?: { serverSlug: string }[]
|
||||
connections?: {
|
||||
serverSlug: string
|
||||
userId: string | null
|
||||
status: string
|
||||
}[]
|
||||
}
|
||||
return (body.connections ?? []).map((c) => c.serverSlug)
|
||||
return (body.connections ?? []).filter((c) => c.status === "active")
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
|
@ -899,7 +972,39 @@ export default function CompanyBrainAutomations() {
|
|||
|
||||
const channels = channelsQuery.data ?? []
|
||||
const automations = listQuery.data ?? []
|
||||
const presets = sortPresets(new Set(appsQuery.data ?? []))
|
||||
const catalogQuery = useQuery({
|
||||
queryKey: ["company-brain-automations", "catalog", "v2"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/mcp-connections/catalog`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok)
|
||||
return {} as Record<string, { name: string; iconDomain?: string }>
|
||||
const body = (await res.json()) as {
|
||||
catalog?: { slug: string; name?: string; iconDomain?: string }[]
|
||||
}
|
||||
return Object.fromEntries(
|
||||
(body.catalog ?? []).map((e) => [
|
||||
e.slug,
|
||||
{ name: e.name ?? e.slug, iconDomain: e.iconDomain },
|
||||
]),
|
||||
)
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
|
||||
const connections = appsQuery.data ?? []
|
||||
const presets = sortPresets(new Set(connections.map((c) => c.serverSlug)))
|
||||
const sharedApps = new Set(
|
||||
connections.filter((c) => c.userId === null).map((c) => c.serverSlug),
|
||||
)
|
||||
const personalOnlyApps = [
|
||||
...new Set(
|
||||
connections
|
||||
.filter((c) => c.userId !== null && !sharedApps.has(c.serverSlug))
|
||||
.map((c) => c.serverSlug),
|
||||
),
|
||||
]
|
||||
const nameFor = (userId: string | null): string | undefined => {
|
||||
if (!userId) return undefined
|
||||
if (userId === user?.id) return "You"
|
||||
|
|
@ -913,10 +1018,34 @@ export default function CompanyBrainAutomations() {
|
|||
}
|
||||
const usedTitles = new Set(automations.map((a) => a.title))
|
||||
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
|
||||
const shownPresets = showAllTemplates
|
||||
? availablePresets
|
||||
: availablePresets.slice(0, 3)
|
||||
const hiddenTemplateCount = availablePresets.length - shownPresets.length
|
||||
const hasList = automations.length > 0 || drafts.length > 0
|
||||
|
||||
const newAutomationButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
</button>
|
||||
)
|
||||
const newAutomationPortal = actionSlot ? (
|
||||
createPortal(newAutomationButton, actionSlot)
|
||||
) : (
|
||||
<div className="flex justify-end">{newAutomationButton}</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
{newAutomationPortal}
|
||||
<div className="flex flex-col gap-3">
|
||||
{automations.map((a) =>
|
||||
openId === a.id ? (
|
||||
|
|
@ -925,6 +1054,9 @@ export default function CompanyBrainAutomations() {
|
|||
id={a.id}
|
||||
initial={toDraft(a)}
|
||||
channels={channels}
|
||||
personalOnlyApps={personalOnlyApps}
|
||||
isAdmin={isAdmin}
|
||||
appCatalog={catalogQuery.data ?? {}}
|
||||
onDone={() => {
|
||||
setOpenId(null)
|
||||
refresh()
|
||||
|
|
@ -953,6 +1085,9 @@ export default function CompanyBrainAutomations() {
|
|||
id={null}
|
||||
initial={draft}
|
||||
channels={channels}
|
||||
personalOnlyApps={personalOnlyApps}
|
||||
isAdmin={isAdmin}
|
||||
appCatalog={catalogQuery.data ?? {}}
|
||||
onDone={() => {
|
||||
removeDraft(key)
|
||||
refresh()
|
||||
|
|
@ -961,37 +1096,38 @@ export default function CompanyBrainAutomations() {
|
|||
/>
|
||||
))}
|
||||
|
||||
{hasList ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
Templates
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
hasList ? "pt-2" : "",
|
||||
"text-[12px] font-medium text-[#6B6B6B]",
|
||||
)}
|
||||
>
|
||||
{showAllTemplates ? "Templates" : "Ideas for your setup"}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availablePresets.map((p) => (
|
||||
{shownPresets.map((p) => (
|
||||
<PresetCard
|
||||
key={p.id}
|
||||
preset={p}
|
||||
onPick={() => addDraft(presetToDraft(p))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hiddenTemplateCount > 0 || showAllTemplates ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDraft(emptyDraft())}
|
||||
onClick={() => setShowAllTemplates((v) => !v)}
|
||||
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]",
|
||||
"self-start text-[12px] font-medium text-[#737B87] transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New automation
|
||||
{showAllTemplates
|
||||
? "Show fewer"
|
||||
: `Show all ${availablePresets.length} templates`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
getConnectionSubtitle,
|
||||
} from "@/components/settings/sync-utils"
|
||||
import type { ImportProvider } from "@/components/settings/sync-utils"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
|
|
@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) {
|
|||
export default function ConnectionsMCP() {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const router = useRouter()
|
||||
const [removeDialog, setRemoveDialog] = useState<{
|
||||
|
|
@ -552,8 +554,10 @@ export default function ConnectionsMCP() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
discounts: promoCode.getDiscounts(),
|
||||
successUrl: `${window.location.origin}/settings#connections`,
|
||||
})
|
||||
promoCode.clear()
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) {
|
|||
export function SlackConnectCard() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [status, setStatus] = useState<SlackStatus | null>(null)
|
||||
const [trialActive, setTrialActive] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -58,10 +59,16 @@ export function SlackConnectCard() {
|
|||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
|
||||
const [slackRes, trialRes] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }),
|
||||
])
|
||||
if (!active) return
|
||||
if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus)
|
||||
if (trialRes.ok) {
|
||||
const trial = (await trialRes.json()) as { active?: boolean }
|
||||
setTrialActive(Boolean(trial.active))
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
|
|
@ -92,7 +99,7 @@ export function SlackConnectCard() {
|
|||
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
) : trialActive ? (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
|
|
@ -100,6 +107,13 @@ export function SlackConnectCard() {
|
|||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/onboarding"
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white/10 px-3.5 py-2 text-[13px] font-semibold text-fg-primary ring-1 ring-surface-border transition-colors hover:bg-white/15"
|
||||
>
|
||||
Finish setting up
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
41
apps/web/components/trial-setup-banner.tsx
Normal file
41
apps/web/components/trial-setup-banner.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client"
|
||||
|
||||
import { ArrowRight, CreditCard } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
|
||||
export function TrialSetupBanner() {
|
||||
const { needsSetup, data } = useTrialStatus()
|
||||
if (!needsSetup) return null
|
||||
|
||||
const endedTrial = data?.reason === "trial_ended"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] px-4 py-3 ring-1 ring-[#4BA0FA]/20 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]/12">
|
||||
<CreditCard className="size-4 text-[#4BA0FA]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-fg-primary">
|
||||
{endedTrial
|
||||
? "Your Company Brain trial has ended"
|
||||
: "Finish setting up Company Brain"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
|
||||
{endedTrial
|
||||
? "Move to Max or Scale to switch the brain back on."
|
||||
: "Add a card to start your 14-day trial. $0 today."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={endedTrial ? "/?settings=billing" : "/onboarding"}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{endedTrial ? "Upgrade" : "Add card"}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,9 +9,12 @@ export function useConnectorAccess(opts?: { enabled?: boolean }) {
|
|||
const hasCompanyBrain = useHasCompanyBrain()
|
||||
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
|
||||
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
|
||||
const hasScale =
|
||||
enabled && hasActivePlan(autumn.data?.subscriptions, "api_scale")
|
||||
return {
|
||||
hasPro,
|
||||
hasMax,
|
||||
hasScale,
|
||||
hasCompanyBrain,
|
||||
connectorAccess: hasPro || hasCompanyBrain,
|
||||
loading: enabled && autumn.isLoading,
|
||||
|
|
|
|||
82
apps/web/hooks/use-promo-code.ts
Normal file
82
apps/web/hooks/use-promo-code.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
|
||||
const PROMO_TOAST_ID = "promo-code"
|
||||
|
||||
function promoCodeKey(orgId: string): string {
|
||||
return `sm.promoCode.org_${orgId}`
|
||||
}
|
||||
|
||||
function readOrgPromoCode(orgId?: string): string | null {
|
||||
if (!orgId || typeof window === "undefined") return null
|
||||
return window.localStorage.getItem(promoCodeKey(orgId))
|
||||
}
|
||||
|
||||
export function usePromoCode() {
|
||||
const { org } = useAuth()
|
||||
const orgId = org?.id
|
||||
|
||||
const getDiscounts = useCallback(() => {
|
||||
const promotionCode = readOrgPromoCode(orgId)
|
||||
return promotionCode ? [{ promotionCode }] : undefined
|
||||
}, [orgId])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (!orgId) return
|
||||
window.localStorage.removeItem(promoCodeKey(orgId))
|
||||
toast.dismiss(PROMO_TOAST_ID)
|
||||
}, [orgId])
|
||||
|
||||
return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
|
||||
}
|
||||
|
||||
export function PromoCodeCapture() {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get("discountCode")
|
||||
if (!code) return
|
||||
|
||||
window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code)
|
||||
url.searchParams.delete("discountCode")
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function PromoCodeHost() {
|
||||
const { org } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!org?.id) return
|
||||
|
||||
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
|
||||
if (pending) {
|
||||
window.localStorage.setItem(promoCodeKey(org.id), pending)
|
||||
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
|
||||
}
|
||||
|
||||
const code = readOrgPromoCode(org.id)
|
||||
if (!code) {
|
||||
toast.dismiss(PROMO_TOAST_ID)
|
||||
return
|
||||
}
|
||||
toast.success("Discount code active", {
|
||||
id: PROMO_TOAST_ID,
|
||||
description: `Code ${code} will apply at checkout.`,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings#billing"),
|
||||
},
|
||||
})
|
||||
}, [org?.id, router])
|
||||
|
||||
return null
|
||||
}
|
||||
34
apps/web/hooks/use-trial-status.ts
Normal file
34
apps/web/hooks/use-trial-status.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
export type TrialStatus = {
|
||||
active: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
/** Distinguishes a named Company Brain org from one whose trial is actually live. */
|
||||
export function useTrialStatus() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["brain", "trial-status"],
|
||||
queryFn: async (): Promise<TrialStatus> => {
|
||||
const res = await fetch(`${BACKEND}/brain/trial/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to load trial status")
|
||||
const data = (await res.json()) as { active?: boolean; reason?: string }
|
||||
return { active: Boolean(data.active), reason: data.reason ?? null }
|
||||
},
|
||||
enabled: isCompanyBrain,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
needsSetup: isCompanyBrain && query.data ? !query.data.active : false,
|
||||
}
|
||||
}
|
||||
|
|
@ -271,4 +271,9 @@ export const analytics = {
|
|||
}) => safeCapture("company_brain_promo_clicked", props),
|
||||
companyBrainPromoDismissed: () =>
|
||||
safeCapture("company_brain_promo_dismissed"),
|
||||
|
||||
brainTrialCardViewed: () => safeCapture("brain_trial_card_viewed"),
|
||||
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
|
||||
brainTrialCheckoutAbandoned: () =>
|
||||
safeCapture("brain_trial_checkout_abandoned"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,18 +185,26 @@ export function getBrainTrialInfo(
|
|||
}
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffix for display
|
||||
* Format a number with K/M/B suffix for display
|
||||
* @example formatUsageNumber(1500000) => "1.5M"
|
||||
* @example formatUsageNumber(50000) => "50K"
|
||||
* @example formatUsageNumber(999950) => "1.0M"
|
||||
*/
|
||||
export function formatUsageNumber(value: number): string {
|
||||
const withSuffix = (n: number, suffix: string) =>
|
||||
n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}`
|
||||
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000
|
||||
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
|
||||
return millions >= 999.95
|
||||
? withSuffix(value / 1_000_000_000, "B")
|
||||
: withSuffix(millions, "M")
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000
|
||||
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
|
||||
return thousands >= 999.95
|
||||
? withSuffix(value / 1_000_000, "M")
|
||||
: withSuffix(thousands, "K")
|
||||
}
|
||||
return value.toString()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { ModelId } from "@/lib/models"
|
||||
|
||||
const OTHER_MODELS: ModelId[] = [
|
||||
"gpt-5.1",
|
||||
"claude-sonnet-4.6",
|
||||
"gemini-2.5-pro",
|
||||
"gpt-5.6-terra",
|
||||
"claude-sonnet-5",
|
||||
"gemini-3.1-pro-preview",
|
||||
]
|
||||
|
||||
function flattenError(e: unknown): string {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
export const models = [
|
||||
{
|
||||
id: "grok-4.3",
|
||||
name: "Grok 4.3",
|
||||
id: "grok-4.5",
|
||||
name: "Grok 4.5",
|
||||
description: "xAI's latest model",
|
||||
},
|
||||
{
|
||||
id: "gpt-5.1",
|
||||
name: "GPT 5.1",
|
||||
id: "gpt-5.6-terra",
|
||||
name: "GPT 5.6",
|
||||
description: "OpenAI's latest model",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-5",
|
||||
name: "Claude Sonnet 5",
|
||||
description: "Anthropic's advanced model",
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 3 Pro",
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro",
|
||||
description: "Google's most capable model",
|
||||
},
|
||||
] as const
|
||||
|
|
@ -25,10 +25,10 @@ export type ModelId = (typeof models)[number]["id"]
|
|||
export type ReasoningEffort = "instant" | "thinking"
|
||||
|
||||
export const modelNames: Record<ModelId, { name: string; version: string }> = {
|
||||
"grok-4.3": { name: "Grok", version: "4.3" },
|
||||
"gpt-5.1": { name: "GPT", version: "5.1" },
|
||||
"claude-sonnet-4.6": { name: "Claude", version: "4.6" },
|
||||
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
|
||||
"grok-4.5": { name: "Grok", version: "4.5" },
|
||||
"gpt-5.6-terra": { name: "GPT", version: "5.6" },
|
||||
"claude-sonnet-5": { name: "Claude", version: "Sonnet 5" },
|
||||
"gemini-3.1-pro-preview": { name: "Gemini", version: "3.1 Pro" },
|
||||
}
|
||||
|
||||
export const reasoningOptions: Array<{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "portless",
|
||||
"dev:app": "next dev --port ${PORT:-3000}",
|
||||
"build": "next build",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "next start",
|
||||
"lint": "biome check --write",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"name": "@repo/hooks",
|
||||
"version": "0.0.0",
|
||||
"private": true
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
anonymousClient,
|
||||
apiKeyClient,
|
||||
emailOTPClient,
|
||||
genericOAuthClient,
|
||||
magicLinkClient,
|
||||
organizationClient,
|
||||
usernameClient,
|
||||
|
|
@ -19,6 +20,7 @@ export const authClient = createAuthClient({
|
|||
usernameClient(),
|
||||
magicLinkClient(),
|
||||
emailOTPClient(),
|
||||
genericOAuthClient(),
|
||||
apiKeyClient(),
|
||||
adminClient(),
|
||||
organizationClient(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => {
|
|||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
|
||||
})
|
||||
|
||||
it("does not treat an already-elapsed forgetAfter as expiring", () => {
|
||||
const past = new Date(Date.now() - 60 * 1000).toISOString()
|
||||
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const mem = makeMemory({ forgetAfter: past, createdAt: old })
|
||||
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
|
||||
})
|
||||
|
||||
it("returns recent color for memories created within 24 hours", () => {
|
||||
const recent = new Date(Date.now() - 1000).toISOString()
|
||||
const mem = makeMemory({ createdAt: recent })
|
||||
|
|
|
|||
|
|
@ -467,10 +467,10 @@ export function MemoryGraph({
|
|||
n.x,
|
||||
n.y,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
graphFitHeight,
|
||||
)
|
||||
},
|
||||
[nodes, containerSize.width, containerSize.height],
|
||||
[nodes, containerSize.width, graphFitHeight],
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function getMemoryBorderColor(
|
|||
if (mem.isForgotten) return colors.memBorderForgotten
|
||||
if (mem.forgetAfter) {
|
||||
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
|
||||
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
|
||||
}
|
||||
const age = Date.now() - new Date(mem.createdAt).getTime()
|
||||
if (age < ONE_DAY_MS) return colors.memBorderRecent
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ Optional for testing:
|
|||
|
||||
### Required
|
||||
- `openai>=1.102.0` - Official OpenAI Python SDK
|
||||
- `supermemory>=3.1.0` - Supermemory client
|
||||
- `supermemory>=3.50.0` - Supermemory client
|
||||
- `requests>=2.25.0` - HTTP requests (fallback)
|
||||
|
||||
### Optional
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "supermemory-openai-sdk"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
description = "Memory tools for OpenAI function calling with supermemory"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export interface ForgetMemoryParams {
|
||||
containerTag: string
|
||||
|
|
@ -7,6 +8,10 @@ export interface ForgetMemoryParams {
|
|||
reason?: string
|
||||
}
|
||||
|
||||
export interface ForgetMemoryRequestOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a memory as forgotten via `DELETE /v4/memories`.
|
||||
*
|
||||
|
|
@ -19,6 +24,7 @@ export async function forgetMemoryRequest(
|
|||
apiKey: string,
|
||||
params: ForgetMemoryParams,
|
||||
baseUrl: string = DEFAULT_BASE_URL,
|
||||
options?: ForgetMemoryRequestOptions,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/v4/memories`, {
|
||||
method: "DELETE",
|
||||
|
|
@ -27,6 +33,7 @@ export async function forgetMemoryRequest(
|
|||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,22 @@ describe("memoryForget", () => {
|
|||
id: "mem_1",
|
||||
reason: "outdated",
|
||||
})
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it("uses a caller-provided signal instead of creating a timeout", async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const controller = new AbortController()
|
||||
|
||||
await forgetMemoryRequest(
|
||||
API_KEY,
|
||||
{ containerTag: "user_1", id: "mem_1" },
|
||||
undefined,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(init.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it("throws a descriptive error on non-2xx responses", async () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
|
||||
import {
|
||||
DocumentsWithMemoriesQuerySchema,
|
||||
ListMemoriesQuerySchema,
|
||||
SearchRequestSchema,
|
||||
Searchv4RequestSchema,
|
||||
} from "./api"
|
||||
|
||||
describe("search threshold schemas", () => {
|
||||
it("do not contain redundant number transforms or unreachable range guards", () => {
|
||||
|
|
@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination query schemas", () => {
|
||||
it("preserve page/limit defaults", () => {
|
||||
const listed = ListMemoriesQuerySchema.parse({})
|
||||
expect(listed.page).toBe(1)
|
||||
expect(listed.limit).toBe(10)
|
||||
|
||||
const docs = DocumentsWithMemoriesQuerySchema.parse({})
|
||||
expect(docs.page).toBe(1)
|
||||
expect(docs.limit).toBe(10)
|
||||
})
|
||||
|
||||
it.each([
|
||||
1, 50, 1100,
|
||||
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
|
||||
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
|
||||
expect(parsed.page).toBe(3)
|
||||
expect(parsed.limit).toBe(25)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -5, 2.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 1.5,
|
||||
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
|
||||
})
|
||||
|
||||
it("ListMemoriesQuerySchema still caps limit at 1100", () => {
|
||||
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -1, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
0, -10, 2.5,
|
||||
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
|
||||
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
|
||||
const parsed = DocumentsWithMemoriesQuerySchema.parse({
|
||||
page: 2,
|
||||
limit: 50,
|
||||
})
|
||||
expect(parsed.page).toBe(2)
|
||||
expect(parsed.limit).toBe(50)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Limit must be a positive integer",
|
||||
})
|
||||
.refine((value) => value <= 1100, {
|
||||
message: "Limit cannot be greater than 1100",
|
||||
})
|
||||
|
|
@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
|
|||
.regex(/^\d+$/)
|
||||
.or(z.number())
|
||||
.transform(Number)
|
||||
.refine((value) => Number.isInteger(value) && value >= 1, {
|
||||
message: "Page must be a positive integer",
|
||||
})
|
||||
.default("1")
|
||||
.openapi({ description: "Page number to fetch", example: "1" }),
|
||||
sort: z
|
||||
|
|
@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z
|
|||
|
||||
export const DocumentsWithMemoriesQuerySchema = z
|
||||
.object({
|
||||
page: z.number().default(1).openapi({
|
||||
page: z.number().int().min(1).default(1).openapi({
|
||||
description: "Page number to fetch",
|
||||
example: 1,
|
||||
}),
|
||||
limit: z.number().default(10).openapi({
|
||||
limit: z.number().int().min(1).default(10).openapi({
|
||||
description: "Number of items per page",
|
||||
example: 10,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,5 +2,8 @@
|
|||
"name": "@repo/validation",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue