mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Compare commits
22 commits
server-v0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d436792e77 | ||
|
|
29c43984fe | ||
|
|
f11d8c4620 | ||
|
|
9652478093 | ||
|
|
3f7b9667c6 | ||
|
|
e4afc770be | ||
|
|
f051af098e | ||
|
|
6cae175852 | ||
|
|
3b0fc9c959 | ||
|
|
3487666481 | ||
|
|
dda56e766e | ||
|
|
818a83a381 | ||
|
|
7b1175cb1a | ||
|
|
20410a6862 | ||
|
|
7d59070ad6 | ||
|
|
18a2dfbe39 | ||
|
|
149589ae7e | ||
|
|
c0eb81c887 | ||
|
|
e2be9c9edd | ||
|
|
5d2b5855fe | ||
|
|
d14b209f7c | ||
|
|
e651045ac5 |
79 changed files with 3599 additions and 1526 deletions
|
|
@ -9,10 +9,9 @@
|
|||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"check-types": "bun run compile",
|
||||
"check-types": "wxt prepare && tsc --noEmit",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,14 @@ Use the dimension published for your chosen model. A mismatch with vectors alrea
|
|||
|
||||
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Model Mixing Bug in v0.0.5 (Exact match returns nothing)**
|
||||
>
|
||||
> In version `v0.0.5`, there was a bug where the server could mix different embedding models between write and read paths (e.g., document ingestion using OpenAI but memory queries using local default embeddings). In multilingual contexts like Japanese (which lacks space tokenization for fallback lexical FTS matching), this caused exact-text memory searches through `/v4/search` and `/v4/profile` to silently return `{"results":[],"total":0}`.
|
||||
>
|
||||
> **Resolution:**
|
||||
> This was fully resolved in `v0.0.7` by locking the embedding plan uniformly across all document and query embedding paths (enforced via a locked plan in the database store). If you are running `v0.0.5` and experiencing this issue, you should upgrade to `v0.0.7` or later.
|
||||
|
||||
## Related
|
||||
|
||||
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { fetchSession, validateOAuthToken } from "./index"
|
||||
import {
|
||||
fetchSession,
|
||||
TransientAuthError,
|
||||
validateApiKey,
|
||||
validateOAuthToken,
|
||||
} from "./index"
|
||||
|
||||
const API_URL = "https://api.example.com"
|
||||
const ISSUER = `${API_URL}/api/auth`
|
||||
|
|
@ -120,4 +125,92 @@ describe("MCP authentication", () => {
|
|||
status: 403,
|
||||
})
|
||||
})
|
||||
|
||||
function sessionResponse() {
|
||||
return Response.json({
|
||||
user: { id: "user_test", email: "test@example.com" },
|
||||
org: { id: "org_test" },
|
||||
role: "owner",
|
||||
accessType: "full",
|
||||
scope: { type: "full", permission: "write" },
|
||||
})
|
||||
}
|
||||
|
||||
it("validates an sm_ API key via the session endpoint", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_valid_key_0123456789abcdef"
|
||||
|
||||
await expect(validateApiKey(key, API_URL)).resolves.toEqual({
|
||||
userId: "user_test",
|
||||
organizationId: "org_test",
|
||||
bearerToken: key,
|
||||
scopes: [],
|
||||
})
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${API_URL}/v3/session`,
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("caches a validated API key within the TTL", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(sessionResponse())
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
const key = "sm_cached_key_0123456789abcdef"
|
||||
|
||||
await validateApiKey(key, API_URL)
|
||||
await validateApiKey(key, API_URL)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("rejects an API key the session endpoint refuses", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
||||
)
|
||||
|
||||
await expect(
|
||||
validateApiKey("sm_revoked_key_0123456789abcdef", API_URL),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("rejects malformed API keys without an API request", async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal("fetch", fetchSpy)
|
||||
|
||||
await expect(validateApiKey("sm_short", API_URL)).resolves.toBeNull()
|
||||
await expect(validateApiKey("not_a_key", API_URL)).resolves.toBeNull()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("surfaces a 500 from the session endpoint as TransientAuthError, not invalid token", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
|
||||
)
|
||||
|
||||
await expect(
|
||||
validateApiKey("sm_outage_key_0123456789abcdef", API_URL),
|
||||
).rejects.toThrow(TransientAuthError)
|
||||
})
|
||||
|
||||
it("surfaces a session-endpoint timeout as TransientAuthError", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("The operation was aborted"), {
|
||||
name: "TimeoutError",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
validateApiKey("sm_timeout_key_0123456789abcd", API_URL),
|
||||
).rejects.toThrow(TransientAuthError)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,6 +52,93 @@ export async function fetchSession(
|
|||
return result.data
|
||||
}
|
||||
|
||||
// Opaque Supermemory API keys (sm_...) authenticate via the session endpoint
|
||||
// instead of JWT verification. Successful lookups are cached per isolate so a
|
||||
// busy MCP session doesn't re-validate on every JSON-RPC message.
|
||||
const API_KEY_PATTERN = /^sm_\S{17,}$/
|
||||
const API_KEY_CACHE_TTL_MS = 60_000
|
||||
const API_KEY_CACHE_MAX_ENTRIES = 1000
|
||||
|
||||
const apiKeyCache = new Map<string, { user: AuthUser; expiresAt: number }>()
|
||||
|
||||
export function isApiKey(token: string): boolean {
|
||||
return API_KEY_PATTERN.test(token)
|
||||
}
|
||||
|
||||
// Upstream was unreachable, not the token being bad: reporting these as invalid_token makes clients discard working credentials.
|
||||
export class TransientAuthError extends Error {
|
||||
readonly status?: number
|
||||
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = "TransientAuthError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSIENT_ERROR_NAMES = new Set([
|
||||
"AbortError",
|
||||
"TimeoutError",
|
||||
"JWKSTimeout",
|
||||
])
|
||||
|
||||
// ERR_JOSE_GENERIC is what jose throws when the JWKS endpoint answers non-200 or unparseable JSON.
|
||||
const TRANSIENT_JOSE_CODES = new Set(["ERR_JWKS_TIMEOUT", "ERR_JOSE_GENERIC"])
|
||||
|
||||
function transientAuthErrorFor(error: unknown): TransientAuthError | null {
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
if (typeof status === "number" && status !== 401 && status !== 403) {
|
||||
return new TransientAuthError(`Session endpoint returned ${status}`, status)
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return new TransientAuthError(`Auth backend unreachable: ${error.message}`)
|
||||
}
|
||||
if (error instanceof Error && TRANSIENT_ERROR_NAMES.has(error.name)) {
|
||||
return new TransientAuthError(error.message)
|
||||
}
|
||||
const code = (error as { code?: unknown } | null)?.code
|
||||
if (typeof code === "string" && TRANSIENT_JOSE_CODES.has(code)) {
|
||||
return new TransientAuthError(
|
||||
`JWKS fetch failed: ${(error as Error).message}`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function validateApiKey(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
): Promise<AuthUser | null> {
|
||||
if (!isApiKey(token)) return null
|
||||
|
||||
const cached = apiKeyCache.get(token)
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.user
|
||||
|
||||
try {
|
||||
const session = await fetchSession(token, apiUrl)
|
||||
const organizationId = session.org?.id
|
||||
if (!organizationId) return null
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
bearerToken: token,
|
||||
scopes: [],
|
||||
}
|
||||
if (apiKeyCache.size >= API_KEY_CACHE_MAX_ENTRIES) apiKeyCache.clear()
|
||||
apiKeyCache.set(token, {
|
||||
user,
|
||||
expiresAt: Date.now() + API_KEY_CACHE_TTL_MS,
|
||||
})
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error("API key validation error:", error)
|
||||
const transient = transientAuthErrorFor(error)
|
||||
if (transient) throw transient
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateOAuthToken(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
|
|
@ -97,6 +184,8 @@ export async function validateOAuthToken(
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("OAuth token validation error:", error)
|
||||
const transient = transientAuthErrorFor(error)
|
||||
if (transient) throw transient
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { z } from "zod"
|
|||
import {
|
||||
containerTagSchema,
|
||||
documentsApiResponseSchema,
|
||||
paginationSchema,
|
||||
memoriesListSchema,
|
||||
type ContainerTag,
|
||||
type DocumentMemoryEntry,
|
||||
type DocumentsApiResponse,
|
||||
type DocumentWithMemories,
|
||||
type MemoriesList,
|
||||
type MemoryEntry,
|
||||
type MemoryEntryHistory,
|
||||
} from "../../shared/types"
|
||||
|
||||
const MAX_CHARS = 200000
|
||||
|
|
@ -34,43 +37,10 @@ export interface DocumentsListResponse {
|
|||
pagination: SdkDocumentListResponse["pagination"]
|
||||
}
|
||||
|
||||
const memoryEntryHistorySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
const memoryEntrySchema = z.looseObject({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
const memoryEntriesResponseSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoryEntriesResponse = z.infer<typeof memoryEntriesResponseSchema>
|
||||
// Memory-entry shapes live in shared/types so the client parser and the
|
||||
// listMemories output schema share one definition and can't drift.
|
||||
export type { MemoryEntry, MemoryEntryHistory }
|
||||
export type MemoryEntriesResponse = MemoriesList
|
||||
|
||||
export type Memory =
|
||||
| {
|
||||
|
|
@ -452,7 +422,7 @@ export class SupermemoryClient {
|
|||
})
|
||||
}
|
||||
|
||||
return memoryEntriesResponseSchema.parse(await response.json())
|
||||
return memoriesListSchema.parse(await response.json())
|
||||
} catch (error) {
|
||||
this.handleError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { AuthInfo } from "@modelcontextprotocol/server"
|
|||
import { createMcpHandler } from "agents/mcp/server"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { validateOAuthToken, type AuthUser } from "./auth"
|
||||
import {
|
||||
isApiKey,
|
||||
TransientAuthError,
|
||||
validateApiKey,
|
||||
validateOAuthToken,
|
||||
type AuthUser,
|
||||
} from "./auth"
|
||||
import { SupermemoryMCP } from "./legacy-protocol-state"
|
||||
import { createSupermemoryServer } from "./server"
|
||||
import type { ActorContext, ServerEnv } from "./types"
|
||||
|
|
@ -42,7 +48,7 @@ app.use(
|
|||
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
||||
// When omitted, Hono echoes Access-Control-Request-Headers. This keeps
|
||||
// modern Mcp-Method/Mcp-Name/Mcp-Param-* routing forward-compatible.
|
||||
exposeHeaders: ["WWW-Authenticate"],
|
||||
exposeHeaders: ["WWW-Authenticate", "Retry-After"],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -123,6 +129,30 @@ function authInfoFor(
|
|||
}
|
||||
}
|
||||
|
||||
type AuthResolution =
|
||||
| { ok: true; user: AuthUser }
|
||||
| { ok: false; reason: "invalid" }
|
||||
| { ok: false; reason: "transient" }
|
||||
|
||||
// Keeps a transient upstream failure distinct from an invalid token.
|
||||
async function resolveAuthUser(
|
||||
token: string,
|
||||
apiUrl: string,
|
||||
mcpResource: string,
|
||||
): Promise<AuthResolution> {
|
||||
try {
|
||||
const user = isApiKey(token)
|
||||
? await validateApiKey(token, apiUrl)
|
||||
: await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
return user ? { ok: true, user } : { ok: false, reason: "invalid" }
|
||||
} catch (error) {
|
||||
if (error instanceof TransientAuthError) {
|
||||
return { ok: false, reason: "transient" }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorizedResponse(
|
||||
resourceMetadataUrl: string,
|
||||
invalidToken = false,
|
||||
|
|
@ -176,8 +206,23 @@ async function handleMcpRequest(
|
|||
|
||||
if (!token) return unauthorizedResponse(resourceMetadataUrl)
|
||||
|
||||
const authUser = await validateOAuthToken(token, apiUrl, mcpResource)
|
||||
if (!authUser) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||
const resolved = await resolveAuthUser(token, apiUrl, mcpResource)
|
||||
if (!resolved.ok && resolved.reason === "transient") {
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32001,
|
||||
message:
|
||||
"Authentication backend temporarily unavailable, please retry",
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 503, headers: { "Retry-After": "5" } },
|
||||
)
|
||||
}
|
||||
if (!resolved.ok) return unauthorizedResponse(resourceMetadataUrl, true)
|
||||
const authUser = resolved.user
|
||||
|
||||
const actor: ActorContext = {
|
||||
userId: authUser.userId,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ export function register(deps: ToolDeps) {
|
|||
description: "Fetch documents with memories for graph display",
|
||||
inputSchema: z.object({
|
||||
containerTag: optionalContainerTagSchema,
|
||||
page: z.number().optional().default(1),
|
||||
limit: z.number().optional().default(200),
|
||||
page: z.number().int().min(1).max(10_000).optional().default(1),
|
||||
limit: z.number().int().min(1).max(1_000).optional().default(200),
|
||||
}),
|
||||
outputSchema: documentsApiResponseSchema,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "./output-schemas"
|
||||
import { textContent, type ToolDeps } from "./types"
|
||||
|
||||
// An out-of-space document reports "not found" on purpose, so the id is not an existence oracle.
|
||||
export function register(deps: ToolDeps) {
|
||||
const inputSchema = z.object({
|
||||
documentId: z
|
||||
|
|
@ -28,8 +29,17 @@ export function register(deps: ToolDeps) {
|
|||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const effectiveTag = await deps.resolveContainerTag()
|
||||
const client = deps.getClient()
|
||||
const document = await client.getDocument(args.documentId)
|
||||
const docTags = document.containerTags
|
||||
if (
|
||||
Array.isArray(docTags) &&
|
||||
docTags.length > 0 &&
|
||||
!docTags.includes(effectiveTag)
|
||||
) {
|
||||
throw new Error("Document not found")
|
||||
}
|
||||
const { content, truncated } = getDocumentContent(document)
|
||||
const structuredContent: GetDocumentOutput = {
|
||||
document: {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export function register(deps: ToolDeps) {
|
|||
description:
|
||||
"Open an interactive form when the user wants to draft, review, edit, or choose the target space before saving information to Supermemory. Use this when the user wants to add a memory but has not supplied final content, or explicitly wants to review supplied content before saving. If the user provides the exact content and asks to save it immediately, use add_memory instead.",
|
||||
inputSchema: z.object({
|
||||
prefill: z.string().optional().describe("Optional content to prefill"),
|
||||
prefill: z
|
||||
.string()
|
||||
.max(200000, "Prefill exceeds maximum length")
|
||||
.optional()
|
||||
.describe("Optional content to prefill"),
|
||||
}),
|
||||
outputSchema: saveViewSchema,
|
||||
_meta: appToolMeta(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod"
|
||||
import {
|
||||
containerTagAccessSchema,
|
||||
memoriesListSchema,
|
||||
paginationSchema,
|
||||
sessionScopeSchema,
|
||||
} from "../../shared/types"
|
||||
|
|
@ -42,33 +43,6 @@ const documentSummarySchema = z.object({
|
|||
summary: z.string().nullable(),
|
||||
})
|
||||
|
||||
const memoryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const memoryEntryOutputSchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export const addMemoryOutputSchema = z.object({
|
||||
action: z.enum(["save", "forget"]),
|
||||
success: z.boolean(),
|
||||
|
|
@ -104,10 +78,9 @@ export const listDocumentsOutputSchema = z.object({
|
|||
|
||||
export type ListDocumentsOutput = z.infer<typeof listDocumentsOutputSchema>
|
||||
|
||||
export const listMemoriesOutputSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntryOutputSchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
// Reuse the shared schema so the tool's output contract stays identical to what
|
||||
// the client parses — the two can't drift.
|
||||
export const listMemoriesOutputSchema = memoriesListSchema
|
||||
|
||||
export type ListMemoriesOutput = z.infer<typeof listMemoriesOutputSchema>
|
||||
|
||||
|
|
@ -149,7 +122,6 @@ export const whoAmIOutputSchema = z.object({
|
|||
version: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sessionId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type WhoAmIOutput = z.infer<typeof whoAmIOutputSchema>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export function register(deps: ToolDeps) {
|
|||
deps.getActiveContainerTag(),
|
||||
])
|
||||
const client = deps.getClientInfo(context)
|
||||
const sessionId = context.sessionId
|
||||
const structuredContent: WhoAmIOutput = {
|
||||
userId: session.user.id,
|
||||
...(session.user.email ? { email: session.user.email } : {}),
|
||||
|
|
@ -34,7 +33,6 @@ export function register(deps: ToolDeps) {
|
|||
: null,
|
||||
...(session.scope ? { scope: session.scope } : {}),
|
||||
...(client ? { client } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
}
|
||||
return {
|
||||
content: [textContent(JSON.stringify(structuredContent))],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const sessionInfoSchema = z.looseObject({
|
|||
email: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
org: z.looseObject({ id: z.string().min(1) }).optional(),
|
||||
role: z.string().optional(),
|
||||
accessType: z.enum(["full", "restricted"]).optional(),
|
||||
containerTags: z.array(containerTagAccessSchema).nullable().optional(),
|
||||
|
|
@ -116,6 +117,49 @@ export const documentsApiResponseSchema = z.object({
|
|||
|
||||
export type DocumentsApiResponse = z.infer<typeof documentsApiResponseSchema>
|
||||
|
||||
// Extracted memory entries from /v4/memories/list. Single source of truth for
|
||||
// both the client parser and the listMemories tool output schema, so the two
|
||||
// can't drift (a mismatch previously produced Ajv "must NOT have additional
|
||||
// properties"). z.object strips unknown API fields on parse, keeping parsed data
|
||||
// matched to the strict MCP output contract while tolerating new API fields.
|
||||
export const memoryEntryHistorySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
parentMemoryId: z.string().nullish(),
|
||||
rootMemoryId: z.string().nullish(),
|
||||
isLatest: z.boolean().optional(),
|
||||
isForgotten: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntryHistory = z.infer<typeof memoryEntryHistorySchema>
|
||||
|
||||
export const memoryEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
memory: z.string(),
|
||||
version: z.number(),
|
||||
isLatest: z.boolean(),
|
||||
isForgotten: z.boolean(),
|
||||
isStatic: z.boolean().optional(),
|
||||
isInference: z.boolean().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sourceCount: z.number().optional(),
|
||||
documentIds: z.array(z.string()).optional(),
|
||||
history: z.array(memoryEntryHistorySchema).optional(),
|
||||
})
|
||||
|
||||
export type MemoryEntry = z.infer<typeof memoryEntrySchema>
|
||||
|
||||
export const memoriesListSchema = z.object({
|
||||
memoryEntries: z.array(memoryEntrySchema),
|
||||
pagination: paginationSchema,
|
||||
})
|
||||
|
||||
export type MemoriesList = z.infer<typeof memoriesListSchema>
|
||||
|
||||
// ViewMessage — discriminated union returned by app tools as `structuredContent`.
|
||||
// The widget uses an exhaustive switch on `view` to dispatch to the correct view component.
|
||||
// Adding a new view here is a compile error in App.tsx until the case is handled.
|
||||
|
|
|
|||
|
|
@ -40,9 +40,18 @@ const extractContent = (memory: SearchResult) => {
|
|||
return "No content available"
|
||||
}
|
||||
|
||||
// metadata.url comes from ingested content, so only http(s) reaches the OS opener.
|
||||
const extractUrl = (memory: SearchResult) => {
|
||||
if (memory.metadata?.url && typeof memory.metadata.url === "string") {
|
||||
return memory.metadata.url
|
||||
const url = memory.metadata.url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
EXA_API_KEY=
|
||||
XAI_API_KEY=
|
||||
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=
|
||||
|
|
|
|||
|
|
@ -6,12 +6,22 @@ import {
|
|||
|
||||
export default async function ConfigureSectionPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ section: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { section } = await params
|
||||
// Default section is canonical at /configure.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
|
||||
// Carry the query across, else deep links like ?mcpSetup= are dropped here.
|
||||
if (section === DEFAULT_CONFIGURE_SECTION) {
|
||||
const query = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(await searchParams)) {
|
||||
if (typeof value === "string") query.set(key, value)
|
||||
else if (Array.isArray(value)) for (const v of value) query.append(key, v)
|
||||
}
|
||||
const search = query.toString()
|
||||
redirect(search ? `/configure?${search}` : "/configure")
|
||||
}
|
||||
if (!isConfigureSection(section)) notFound()
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
58
apps/web/app/api/mcp-icon/route.ts
Normal file
58
apps/web/app/api/mcp-icon/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import iconDomains from "@/lib/mcp-icon-domains.json"
|
||||
|
||||
const DOMAIN_RE =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i
|
||||
const MAX_ICON_BYTES = 256 * 1024
|
||||
|
||||
const ALLOWED_DOMAINS = new Set(iconDomains.domains)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const domain = request.nextUrl.searchParams
|
||||
.get("domain")
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
if (!domain || !DOMAIN_RE.test(domain) || !ALLOWED_DOMAINS.has(domain)) {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=128`,
|
||||
{ next: { revalidate: 60 * 60 * 24 * 7 } },
|
||||
)
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
if (!response.ok || !contentType.startsWith("image/")) {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length") ?? 0)
|
||||
if (contentLength > MAX_ICON_BYTES) {
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
if (!response.body) return new NextResponse(null, { status: 404 })
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let bytes = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
bytes += value.byteLength
|
||||
if (bytes > MAX_ICON_BYTES) {
|
||||
await reader.cancel()
|
||||
return new NextResponse(null, { status: 413 })
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
const body = new Uint8Array(bytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=86400, s-maxage=604800, stale-while-revalidate=2592000",
|
||||
"content-type": contentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { hasVerifiedSession } from "@/lib/verify-session"
|
||||
|
||||
interface OGResponse {
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -13,6 +15,42 @@ function isValidUrl(urlString: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
const MAX_HTML_BYTES = 2_000_000
|
||||
|
||||
// OG parsing only needs <head>, so cap the read rather than buffering the whole body.
|
||||
async function readBoundedText(
|
||||
response: Response,
|
||||
maxBytes = MAX_HTML_BYTES,
|
||||
): Promise<string | null> {
|
||||
const contentLength = response.headers.get("content-length")
|
||||
if (contentLength && Number(contentLength) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
if (!response.body) {
|
||||
return null
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
const merged = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new TextDecoder().decode(merged)
|
||||
}
|
||||
|
||||
function isPrivateIPv4Octets(a: number, b: number): boolean {
|
||||
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
|
||||
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
|
||||
|
|
@ -247,6 +285,10 @@ function resolveImageUrl(
|
|||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!(await hasVerifiedSession(request))) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const url = searchParams.get("url")
|
||||
|
||||
|
|
@ -332,7 +374,13 @@ export async function GET(request: Request) {
|
|||
if (contentType && !contentType.includes("text/html")) {
|
||||
return Response.json({ title: "", description: "" })
|
||||
}
|
||||
const html = await secondResponse.text()
|
||||
const html = await readBoundedText(secondResponse)
|
||||
if (html === null) {
|
||||
return Response.json(
|
||||
{ error: "Response too large" },
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
return processHtml(html, redirectUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -349,7 +397,10 @@ export async function GET(request: Request) {
|
|||
return Response.json({ title: "", description: "" })
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const html = await readBoundedText(response)
|
||||
if (html === null) {
|
||||
return Response.json({ error: "Response too large" }, { status: 413 })
|
||||
}
|
||||
return processHtml(html, trimmedUrl)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
type AccountSource = "x" | "linkedin"
|
||||
|
||||
type ParsedAccount = {
|
||||
handle: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function parseXAccount(value: string): ParsedAccount | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
let handle = trimmed.replace(/^@/, "")
|
||||
const lowerValue = handle.toLowerCase()
|
||||
|
||||
if (lowerValue.includes("x.com") || lowerValue.includes("twitter.com")) {
|
||||
try {
|
||||
const url = new URL(
|
||||
handle.startsWith("http://") || handle.startsWith("https://")
|
||||
? handle
|
||||
: `https://${handle}`,
|
||||
)
|
||||
handle = url.pathname.split("/").filter(Boolean)[0] ?? ""
|
||||
} catch {
|
||||
handle = handle.match(/(?:x\.com|twitter\.com)\/([^/\s?#]+)/i)?.[1] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
handle = handle.replace(/^@/, "").split(/[/?#]/)[0] ?? ""
|
||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null
|
||||
|
||||
return { handle, url: `https://x.com/${handle}` }
|
||||
}
|
||||
|
||||
function parseLinkedInAccount(value: string): ParsedAccount | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
||||
? trimmed
|
||||
: `https://${trimmed}`,
|
||||
)
|
||||
const match = url.pathname.match(/\/(in|pub)\/([^/\s?#]+)/i)
|
||||
const handle = match?.[2]
|
||||
if (!handle) return null
|
||||
|
||||
return {
|
||||
handle,
|
||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
||||
}
|
||||
} catch {
|
||||
const match = trimmed.match(/linkedin\.com\/(in|pub)\/([^/\s?#]+)/i)
|
||||
const handle = match?.[2]
|
||||
if (!handle) return null
|
||||
|
||||
return {
|
||||
handle,
|
||||
url: `https://www.linkedin.com/${match[1]?.toLowerCase()}/${handle}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseAccount(
|
||||
source: AccountSource,
|
||||
value: string,
|
||||
): ParsedAccount | null {
|
||||
return source === "x" ? parseXAccount(value) : parseLinkedInAccount(value)
|
||||
}
|
||||
|
||||
function looksUnavailable(source: AccountSource, html: string) {
|
||||
const lowerHtml = html.toLowerCase()
|
||||
if (source === "x") {
|
||||
return (
|
||||
lowerHtml.includes("this account doesn") ||
|
||||
lowerHtml.includes("account suspended") ||
|
||||
lowerHtml.includes("profile not found")
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
lowerHtml.includes("profile not found") ||
|
||||
lowerHtml.includes("page not found") ||
|
||||
lowerHtml.includes("this linkedin profile is unavailable")
|
||||
)
|
||||
}
|
||||
|
||||
function linkedinFallback(account: ParsedAccount, status?: number) {
|
||||
return Response.json({
|
||||
found: null,
|
||||
verified: false,
|
||||
reason: "unable_to_verify_linkedin",
|
||||
handle: account.handle,
|
||||
status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyXAccount(account: ParsedAccount, signal: AbortSignal) {
|
||||
const oembedUrl = new URL("https://publish.twitter.com/oembed")
|
||||
oembedUrl.searchParams.set("url", account.url)
|
||||
|
||||
const response = await fetch(oembedUrl, {
|
||||
signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
return Response.json({
|
||||
found: false,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Unable to verify account",
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
found: true,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const source = searchParams.get("source")
|
||||
const value = searchParams.get("value")
|
||||
|
||||
if (source !== "x" && source !== "linkedin") {
|
||||
return Response.json({ error: "Invalid account source" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!value?.trim()) {
|
||||
return Response.json({ error: "Missing account value" }, { status: 400 })
|
||||
}
|
||||
|
||||
const account = parseAccount(source, value)
|
||||
if (!account) {
|
||||
return Response.json({ found: false, reason: "invalid" }, { status: 400 })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 7000)
|
||||
|
||||
try {
|
||||
if (source === "x") {
|
||||
return await verifyXAccount(account, controller.signal)
|
||||
}
|
||||
|
||||
const response = await fetch(account.url, {
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; SuperMemory/1.0; +https://supermemory.ai)",
|
||||
},
|
||||
})
|
||||
|
||||
if (response.status === 404 || response.status === 410) {
|
||||
return Response.json({
|
||||
found: false,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account, response.status)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: "Unable to verify account",
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const found = !looksUnavailable(source, html)
|
||||
|
||||
return Response.json({
|
||||
found,
|
||||
handle: account.handle,
|
||||
status: response.status,
|
||||
url: account.url,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: "Account lookup timed out", handle: account.handle },
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
|
||||
console.error("Account status lookup failed:", error)
|
||||
if (source === "linkedin") {
|
||||
return linkedinFallback(account)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{ error: "Unable to verify account", handle: account.handle },
|
||||
{ status: 502 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
export interface ExaContentResult {
|
||||
url: string
|
||||
text: string
|
||||
title: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface ExaApiResponse {
|
||||
results: ExaContentResult[]
|
||||
}
|
||||
|
||||
const exaApiKey = process.env.EXA_API_KEY
|
||||
if (!exaApiKey) {
|
||||
console.error(
|
||||
"EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503",
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (!exaApiKey) {
|
||||
return Response.json(
|
||||
{ error: "Content extraction is unavailable" },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
const { urls } = await request.json()
|
||||
|
||||
if (!Array.isArray(urls) || urls.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "Invalid input: urls must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!urls.every((url) => typeof url === "string" && url.trim())) {
|
||||
return Response.json(
|
||||
{ error: "Invalid input: all urls must be non-empty strings" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.exa.ai/contents", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": exaApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
urls,
|
||||
text: true,
|
||||
livecrawl: "fallback",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"Exa API request failed:",
|
||||
response.status,
|
||||
response.statusText,
|
||||
)
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch content from Exa API" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
const data: ExaApiResponse = await response.json()
|
||||
return Response.json({ results: data.results })
|
||||
} catch (error) {
|
||||
console.error("Exa API request error:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { xai } from "@ai-sdk/xai"
|
||||
import { generateText } from "ai"
|
||||
|
||||
interface ResearchRequest {
|
||||
xUrl: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
const ALLOWED_X_HOSTS: ReadonlySet<string> = new Set([
|
||||
"x.com",
|
||||
"www.x.com",
|
||||
"twitter.com",
|
||||
"www.twitter.com",
|
||||
"mobile.twitter.com",
|
||||
])
|
||||
|
||||
const X_URL_FALLBACK_REGEX =
|
||||
/^(?:https?:\/\/)?(?:x\.com|www\.x\.com|twitter\.com|www\.twitter\.com|mobile\.twitter\.com)\/([^/\s?#]+)/i
|
||||
|
||||
function isXHost(hostname: string): boolean {
|
||||
return ALLOWED_X_HOSTS.has(hostname.toLowerCase())
|
||||
}
|
||||
|
||||
function extractHandle(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return ""
|
||||
|
||||
let handle = trimmed.replace(/^@+/, "")
|
||||
const lower = handle.toLowerCase()
|
||||
|
||||
if (lower.includes("x.com") || lower.includes("twitter.com")) {
|
||||
try {
|
||||
const parsed = new URL(
|
||||
handle.startsWith("http://") || handle.startsWith("https://")
|
||||
? handle
|
||||
: `https://${handle}`,
|
||||
)
|
||||
handle = isXHost(parsed.hostname)
|
||||
? (parsed.pathname.split("/").filter(Boolean)[0] ?? "")
|
||||
: ""
|
||||
} catch {
|
||||
handle = handle.match(X_URL_FALLBACK_REGEX)?.[1] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
return handle.replace(/^@+/, "").split(/[/?#]/)[0]?.toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
function finalPrompt(handle: string, userContext: string) {
|
||||
return `You are researching a user based on their X/Twitter profile to help personalize their experience.
|
||||
|
||||
X Handle: @${handle}${userContext}
|
||||
|
||||
Please analyze this X/Twitter profile and provide a comprehensive but concise summary of the user. Include:
|
||||
- Professional background and current role (if available)
|
||||
- Key interests and topics they engage with
|
||||
- Notable projects, achievements, or affiliations
|
||||
- Their expertise areas
|
||||
- Any other relevant information that helps understand who they are
|
||||
|
||||
Format the response as clear, readable paragraphs. Focus on factual information from their profile. If certain information is not available, skip that section rather than speculating.`
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { xUrl, name, email }: ResearchRequest = await req.json()
|
||||
|
||||
if (!xUrl?.trim()) {
|
||||
return Response.json(
|
||||
{ error: "X/Twitter URL or handle is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const handle = extractHandle(xUrl)
|
||||
|
||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) {
|
||||
return Response.json(
|
||||
{ error: "Could not parse a valid X/Twitter handle from the input" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const contextParts: string[] = []
|
||||
if (name) contextParts.push(`Name: ${name}`)
|
||||
if (email) contextParts.push(`Email: ${email}`)
|
||||
const userContext =
|
||||
contextParts.length > 0
|
||||
? `\n\nAdditional context about the user:\n${contextParts.join("\n")}`
|
||||
: ""
|
||||
|
||||
const { text } = await generateText({
|
||||
model: xai.responses("grok-4-fast"),
|
||||
prompt: finalPrompt(handle, userContext),
|
||||
tools: {
|
||||
web_search: xai.tools.webSearch(),
|
||||
x_search: xai.tools.xSearch({
|
||||
allowedXHandles: [handle],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return Response.json({ text })
|
||||
} catch (error) {
|
||||
console.error("Research API error:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import type { z } from "zod"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -42,6 +44,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 +312,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 +334,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
|
||||
|
|
@ -501,7 +507,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
},
|
||||
})
|
||||
|
||||
const notify = useConnectorNotify()
|
||||
|
||||
// Every connect path funnels here; a `disabled` button would swallow the click.
|
||||
const handleConnect = (provider: ConnectorProvider) => {
|
||||
if (connectorPause(provider)) {
|
||||
notify.request(provider)
|
||||
return
|
||||
}
|
||||
setConnectingProvider(provider)
|
||||
addConnectionMutation.mutate({
|
||||
provider,
|
||||
|
|
@ -544,14 +557,32 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="size-6 text-[#737373]" />
|
||||
<div className="space-y-[6px] flex-1">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
{connectorPause(provider) && (
|
||||
<span className="shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{provider === "google-drive" ? (
|
||||
{connectorPause(provider) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notify.request(provider)}
|
||||
title={connectorPause(provider)?.message}
|
||||
className="bg-[#14161A] text-[#FAFAFA] text-[14px] font-medium px-3 h-8 rounded-md border border-[rgba(82,89,102,0.3)] hover:bg-[#1B1F24] transition-colors"
|
||||
>
|
||||
{notify.isRequested(provider)
|
||||
? "We'll email you"
|
||||
: "Notify me"}
|
||||
</button>
|
||||
) : provider === "google-drive" ? (
|
||||
<div className="flex items-center rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -717,6 +748,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex flex-col">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (connectorPause("google-drive")) {
|
||||
notify.request("google-drive")
|
||||
return
|
||||
}
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
|
|
@ -737,6 +772,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (connectorPause("google-drive")) {
|
||||
notify.request("google-drive")
|
||||
return
|
||||
}
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { isConnectorPaused } from "@/lib/connector-availability"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import {
|
||||
useQuickNoteDraftReset,
|
||||
|
|
@ -603,6 +604,10 @@ export function AppExperience() {
|
|||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
if (integration && isConnectorPaused(integration)) {
|
||||
void setViewMode("integrations")
|
||||
return
|
||||
}
|
||||
if (integration === "notion" || integration === "google-drive") {
|
||||
void setAddDoc("connect")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { Gmail, Granola, Notion } from "@ui/assets/icons"
|
||||
import { Gmail, GoogleDrive, Granola, Notion } from "@ui/assets/icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export function SlackMark({ className }: { className?: string }) {
|
||||
|
|
@ -99,6 +99,8 @@ export function brainConnectorIcon(
|
|||
className = "size-[18px]",
|
||||
): React.ReactNode {
|
||||
switch (slug) {
|
||||
case "google-drive":
|
||||
return <GoogleDrive className={className} />
|
||||
case "gmail":
|
||||
return <Gmail className={className} />
|
||||
case "github":
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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 { BrainSetupModal } from "@/components/brain-setup-modal"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
|
@ -172,7 +173,8 @@ export function BrainHomeView() {
|
|||
const o = useBrainOverview()
|
||||
const trial = useBrainTrial()
|
||||
const board = useConnectionsBoard()
|
||||
const { needsSetup } = useTrialStatus()
|
||||
const { data: trialStatus } = useTrialStatus()
|
||||
const { org: activeOrg } = useAuth()
|
||||
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
||||
const milestones = [
|
||||
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
||||
|
|
@ -184,6 +186,10 @@ export function BrainHomeView() {
|
|||
]
|
||||
const milestonesDone = milestones.filter(Boolean).length
|
||||
const milestonesTotal = milestones.length
|
||||
// Positive gate: a slow trial request would otherwise prompt an expired org.
|
||||
const showSetupPrompt = Boolean(
|
||||
board.slack && !board.slack.connected && trialStatus?.active,
|
||||
)
|
||||
const showTimeline =
|
||||
!o.loading && (trial.state !== "none" || milestonesDone < milestonesTotal)
|
||||
|
||||
|
|
@ -199,7 +205,7 @@ export function BrainHomeView() {
|
|||
setupTotal={milestonesTotal}
|
||||
lastUpdatedAt={o.lastUpdatedAt}
|
||||
/>
|
||||
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
|
||||
<BrainSetupModal enabled={showSetupPrompt} orgId={activeOrg?.id} />
|
||||
<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} />}
|
||||
|
|
@ -221,6 +227,7 @@ export function BrainHomeView() {
|
|||
<AskInSlackCard board={board} />
|
||||
</div>
|
||||
</div>
|
||||
{showSetupPrompt && <SlackBanner />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ const BACKEND =
|
|||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
const cardStyle = {
|
||||
export const cardStyle = {
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
}
|
||||
const tileStyle = {
|
||||
export const tileStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
|
|
|||
178
apps/web/components/brain-setup-modal.tsx
Normal file
178
apps/web/components/brain-setup-modal.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import { Check } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { cardStyle, tileStyle } from "@/components/brain-home/connections-board"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const dismissKey = (orgId: string) => `sm_brain_setup_modal_dismissed:${orgId}`
|
||||
|
||||
const CALL_FEATURES = [
|
||||
"We install Slack with you, live",
|
||||
"Connect Gmail, Notion, Linear and the rest",
|
||||
"Wire up plugins and answer your questions",
|
||||
]
|
||||
|
||||
export function BrainSetupModal({
|
||||
enabled,
|
||||
orgId,
|
||||
}: {
|
||||
enabled: boolean
|
||||
orgId: string | undefined
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Also closes if the org stops being eligible while the dialog is open.
|
||||
if (!enabled || !orgId) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
let dismissed = false
|
||||
try {
|
||||
dismissed = localStorage.getItem(dismissKey(orgId)) === "1"
|
||||
} catch {}
|
||||
if (dismissed) return
|
||||
setOpen(true)
|
||||
analytics.brainSetupModalSeen()
|
||||
}, [enabled, orgId])
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
if (!orgId) return
|
||||
try {
|
||||
localStorage.setItem(dismissKey(orgId), "1")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"w-[94%]! max-w-[520px]! rounded-[22px] border border-white/[0.08] bg-[#1B1F24] p-5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={cardStyle}
|
||||
>
|
||||
<DialogTitle className="sr-only">Get set up</DialogTitle>
|
||||
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Get set up
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Your brain is ready. Let's get it working where your team is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 overflow-hidden rounded-[14px] border border-[#2261CA66] bg-[#00173C] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<span className="absolute right-5 top-5 inline-flex h-[18px] items-center rounded-[3px] bg-[#4BA0FA] px-1.5 text-[10px] font-bold uppercase tracking-[0.36px] text-[#00171A]">
|
||||
Fastest
|
||||
</span>
|
||||
|
||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#7E9BC4]">
|
||||
Setup call
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[24px] font-bold leading-none tracking-[-0.34px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Do it with us
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-2 text-[13px] leading-snug text-[#C8D0DA]",
|
||||
)}
|
||||
>
|
||||
30 minutes, live with our team. You leave with a working brain.
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 flex flex-col gap-3">
|
||||
{CALL_FEATURES.map((feature) => (
|
||||
<li
|
||||
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
|
||||
key={feature}
|
||||
>
|
||||
<Check className="mt-0.5 size-3.5 shrink-0 text-[#fafafa]" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={COMPANY_BRAIN_CAL_HREF}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => {
|
||||
analytics.brainSetupCallClicked({ surface: "setup_modal" })
|
||||
analytics.brainSetupModalPicked({ choice: "call" })
|
||||
close()
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-5 flex h-11 items-center justify-center rounded-[10px] bg-white text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.01]",
|
||||
)}
|
||||
>
|
||||
Book a setup call
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
<div className="flex min-h-[52px] items-center gap-3 px-3 py-2.5">
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
<SlackMark className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
|
||||
Add to Slack
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
Rather set it up yourself? Takes a minute.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
onClick={() => {
|
||||
analytics.brainSetupModalPicked({ choice: "slack" })
|
||||
close()
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex w-[88px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] px-3 py-1.5 text-[12px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
Install
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="mx-auto mt-4 block text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A1A1AA]"
|
||||
>
|
||||
I'll do it later
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import { FeedbackModal } from "@/components/feedback-modal"
|
|||
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
|
||||
import { SlackMark } from "@/components/brain-connector-icons"
|
||||
import { BrainTrialPill } from "@/components/brain-trial-pill"
|
||||
import { SetupCallLink } from "@/components/setup-call-link"
|
||||
import { GraphIcon } from "@/components/integration-icons"
|
||||
import { SpaceSelector } from "@/components/space-selector"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
|
|
@ -538,29 +539,7 @@ export function CompanyBrainHeader({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canInvite && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={handleInvite}
|
||||
aria-label="Invite teammates"
|
||||
>
|
||||
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Invite</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Invite teammates
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<SetupCallLink />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
82
apps/web/components/directory/connector-card.tsx
Normal file
82
apps/web/components/directory/connector-card.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ReactNode } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
// Shared connector/integration card shell: icon, name, subtitle, optional
|
||||
// top-right slot, and a footer split into a status side and an action side.
|
||||
export function ConnectorCard({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
topRight,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
topRight?: ReactNode
|
||||
footerLeft: ReactNode
|
||||
footerRight?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{topRight}
|
||||
</div>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
|
||||
<div className="flex min-w-0 items-center gap-3">{footerLeft}</div>
|
||||
{footerRight}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScopeChip({
|
||||
label,
|
||||
connected,
|
||||
}: {
|
||||
label: string
|
||||
connected: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-[7px] shrink-0 rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
117
apps/web/components/directory/section-rail.tsx
Normal file
117
apps/web/components/directory/section-rail.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export const sectionLabelClass = cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)
|
||||
|
||||
// Horizontally scrollable card rail with a section heading — shared by the
|
||||
// main integrations directory and the Company Brain connections directory.
|
||||
// Arrows appear only when the content actually overflows.
|
||||
export function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
labelSlot,
|
||||
scrollbar = "hidden",
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
labelSlot?: ReactNode
|
||||
scrollbar?: "hidden" | "visible"
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setHasOverflow(el.scrollWidth > el.clientWidth + 4)
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className={sectionLabelClass}>{label}</h3>
|
||||
{labelSlot}
|
||||
</div>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
{hasOverflow ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1",
|
||||
scrollbar === "visible" ? "scrollbar-thin sm:pb-2" : "scrollbar-none",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Standard card width inside a rail: full-width stacked on mobile, 2-up on
|
||||
// small screens, 3-up on large.
|
||||
export const railItemClass =
|
||||
"w-full sm:shrink-0 sm:grow-0 sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)]"
|
||||
|
|
@ -61,7 +61,6 @@ export function FullscreenNoteModal({
|
|||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent: string) => {
|
||||
console.log("handleContentChange", newContent)
|
||||
setContent(newContent)
|
||||
setDraft(newContent)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import { SectionRail } from "@/components/directory/section-rail"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
|
@ -36,13 +37,17 @@ import {
|
|||
FileText,
|
||||
Globe,
|
||||
Info,
|
||||
Bell,
|
||||
Loader,
|
||||
Pause,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { formatRelativeTime } from "@/components/settings/sync-utils"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import { useConnectorAccess } from "@/hooks/use-connector-access"
|
||||
import { useConnectionHealth } from "@/hooks/use-connection-health"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
|
@ -71,8 +76,14 @@ import {
|
|||
isFreeTierPlugin,
|
||||
normalizePluginClientId,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
import {
|
||||
CopyButton,
|
||||
INSET,
|
||||
InstallSteps,
|
||||
PillButton,
|
||||
} from "./integrations/install-steps"
|
||||
import {
|
||||
ShortcutsConnectButtons,
|
||||
useShortcutsConnect,
|
||||
|
|
@ -80,6 +91,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 +548,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 +567,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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -591,6 +603,52 @@ export function DetailWrapper({
|
|||
)
|
||||
}
|
||||
|
||||
function NotifyMeButton({
|
||||
provider,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
provider: string
|
||||
title?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { isRequested, request } = useConnectorNotify()
|
||||
const requested = isRequested(provider)
|
||||
return (
|
||||
<PillButton
|
||||
className={requested ? "cursor-default opacity-60" : undefined}
|
||||
onClick={() => {
|
||||
onClick?.()
|
||||
if (!requested) request(provider)
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{requested ? (
|
||||
<>
|
||||
<Check className="size-3.5" /> We'll email you
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="size-3.5" /> Notify me
|
||||
</>
|
||||
)}
|
||||
</PillButton>
|
||||
)
|
||||
}
|
||||
|
||||
function PausedChip() {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-full bg-[#F5A524]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#F5A524]",
|
||||
)}
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProChip({ children = "Pro" }: { children?: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -637,6 +695,206 @@ function IconBox({
|
|||
)
|
||||
}
|
||||
|
||||
const PLUGIN_COMMANDS: InstallStep[] = [
|
||||
{
|
||||
code: "npx supermemory plugin",
|
||||
copyLabel: "Install plugins",
|
||||
title: "Install plugins",
|
||||
description:
|
||||
"Detect Claude Code, Cursor, OpenCode, and Codex, install your selections, then approve OAuth once in the browser.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin login",
|
||||
copyLabel: "Reconnect plugins",
|
||||
title: "Reconnect plugins",
|
||||
description:
|
||||
"Run browser OAuth again for plugins that are already installed, without reinstalling them.",
|
||||
},
|
||||
{
|
||||
code: "npx supermemory plugin uninstall",
|
||||
copyLabel: "Uninstall plugins",
|
||||
title: "Uninstall plugins",
|
||||
description:
|
||||
"Remove selected plugin integrations while keeping your credentials and memories.",
|
||||
},
|
||||
]
|
||||
|
||||
const PLUGIN_COMMAND_CLIENTS = [
|
||||
"claude_code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"opencode",
|
||||
] as const
|
||||
|
||||
type PluginSetupTab = "agent" | "manual"
|
||||
|
||||
const PLUGIN_CLI_TARGETS: Partial<Record<string, string>> = {
|
||||
claude_code: "claude",
|
||||
codex: "codex",
|
||||
cursor: "cursor",
|
||||
opencode: "opencode",
|
||||
}
|
||||
|
||||
function pluginAgentPrompt(plugin: PluginInfo): string {
|
||||
const cliTarget = PLUGIN_CLI_TARGETS[plugin.id]
|
||||
if (cliTarget) {
|
||||
return `Install and connect the Supermemory plugin for ${plugin.name} on this machine. Run \`npx supermemory plugin --only ${cliTarget}\`, complete the browser OAuth flow when it opens, then verify the plugin is installed and authenticated.`
|
||||
}
|
||||
|
||||
const docsInstruction = plugin.docsUrl
|
||||
? ` Follow the official setup instructions at ${plugin.docsUrl}.`
|
||||
: " Follow its official setup instructions."
|
||||
return `Install and connect the Supermemory integration for ${plugin.name} on this machine.${docsInstruction} Complete authentication securely, then verify the integration is working.`
|
||||
}
|
||||
|
||||
function PluginSetupMethodTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PluginSetupTab
|
||||
onChange: (value: PluginSetupTab) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row gap-0.5 rounded-full bg-[#0D121A] p-0.5",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]",
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label="Setup method"
|
||||
>
|
||||
{(["agent", "manual"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={cn(
|
||||
"min-h-8 flex-1 rounded-full px-3 text-center text-[12px] font-medium transition-colors",
|
||||
value === tab
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
onClick={() => onChange(tab)}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={value === tab}
|
||||
>
|
||||
{tab === "agent" ? "Agent instructions" : "Manual instructions"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginAgentInstructions({ plugin }: { plugin: PluginInfo }) {
|
||||
const prompt = pluginAgentPrompt(plugin)
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[12px] leading-[1.6] text-[#E4E4E7]">
|
||||
{prompt}
|
||||
</p>
|
||||
<CopyButton text={prompt} label="Agent instructions" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginCommandsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[620px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Supermemory plugin commands
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Claude Code, Cursor, Codex, and OpenCode"
|
||||
className="flex shrink-0 -space-x-2"
|
||||
>
|
||||
{PLUGIN_COMMAND_CLIENTS.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
return (
|
||||
<span
|
||||
key={pluginId}
|
||||
className="flex size-8 items-center justify-center rounded-[9px] border border-white/[0.12] bg-[#0D121A] p-1.5 shadow-sm"
|
||||
>
|
||||
<Image
|
||||
src={plugin.icon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Plugin commands
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">
|
||||
Install, reconnect, or remove integrations from one CLI.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-3 sm:p-4",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<InstallSteps steps={PLUGIN_COMMANDS} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 pt-1">
|
||||
<p className="text-[11px] text-[#737373]">
|
||||
Run these commands from your terminal.
|
||||
</p>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type InfoUseCase = {
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -2067,6 +2325,8 @@ function ItemCard({
|
|||
docsUrl,
|
||||
leftIndicator,
|
||||
statusSlot,
|
||||
layoutClassName,
|
||||
paused,
|
||||
}: {
|
||||
actionSlot: ReactNode
|
||||
infoActionSlot?: ReactNode
|
||||
|
|
@ -2081,6 +2341,8 @@ function ItemCard({
|
|||
docsUrl?: string
|
||||
leftIndicator?: ReactNode
|
||||
statusSlot?: ReactNode
|
||||
layoutClassName?: string
|
||||
paused?: boolean
|
||||
}) {
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
return (
|
||||
|
|
@ -2098,6 +2360,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 +2380,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}
|
||||
|
|
@ -2127,7 +2397,8 @@ function ItemCard({
|
|||
>
|
||||
{name}
|
||||
</span>
|
||||
{isNew && <NewChip />}
|
||||
{paused && <PausedChip />}
|
||||
{isNew && !paused && <NewChip />}
|
||||
{max ? <ProChip>Max</ProChip> : pro && <ProChip />}
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -2139,7 +2410,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 +2427,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()}
|
||||
>
|
||||
|
|
@ -2453,95 +2734,6 @@ function CategoryFilterToggle({
|
|||
)
|
||||
}
|
||||
|
||||
function SectionRail({
|
||||
label,
|
||||
children,
|
||||
headerSlot,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
headerSlot?: ReactNode
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
setCanScrollLeft(el.scrollLeft > 4)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
el.addEventListener("scrollend", update)
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
el.removeEventListener("scrollend", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
const scrollBy = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
|
||||
setTimeout(update, 450)
|
||||
}
|
||||
|
||||
const arrowClass = cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
|
||||
"hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-semibold tracking-[-0.01em] text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
<div className="hidden items-center gap-1.5 sm:flex">
|
||||
{headerSlot}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show previous"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollBy(-1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show more"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollBy(1)}
|
||||
className={arrowClass}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-none flex flex-col gap-1.5 sm:-mx-1 sm:flex-row sm:gap-3 sm:overflow-x-auto sm:px-1"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationsView({
|
||||
publicMode = false,
|
||||
onOpenDocument,
|
||||
|
|
@ -2555,6 +2747,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({
|
||||
|
|
@ -2566,18 +2759,21 @@ export function IntegrationsView({
|
|||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
|
||||
const [pluginCommandsOpen, setPluginCommandsOpen] = useState(false)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
open: boolean
|
||||
key: string
|
||||
pluginId: string | null
|
||||
loading: boolean
|
||||
}>({ open: false, key: "", pluginId: null, loading: false })
|
||||
const [pluginSetupTab, setPluginSetupTab] = useState<PluginSetupTab>("agent")
|
||||
const openPluginSetup = useCallback((pluginId: string) => {
|
||||
setPluginSetupTab("agent")
|
||||
setNewKey({ open: true, key: "", pluginId, loading: false })
|
||||
}, [])
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryFn: async () => {
|
||||
|
|
@ -2747,11 +2943,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
|
||||
}
|
||||
|
|
@ -2761,12 +2952,7 @@ export function IntegrationsView({
|
|||
},
|
||||
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
||||
onError: (err) => {
|
||||
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
|
||||
setNewKey((s) =>
|
||||
s.loading
|
||||
? { open: false, key: "", pluginId: null, loading: false }
|
||||
: s,
|
||||
)
|
||||
setNewKey((s) => ({ ...s, loading: false }))
|
||||
toast.error("Failed to connect plugin", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
})
|
||||
|
|
@ -2776,10 +2962,32 @@ export function IntegrationsView({
|
|||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||
},
|
||||
onSuccess: (data, pluginId) => {
|
||||
setNewKey({ open: true, key: data.key, pluginId, loading: false })
|
||||
setNewKey((s) =>
|
||||
s.open && s.pluginId === pluginId
|
||||
? { ...s, key: data.key, loading: false }
|
||||
: s,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const generatePluginKey = () => {
|
||||
const pluginId = newKey.pluginId
|
||||
if (
|
||||
!pluginId ||
|
||||
newKey.key ||
|
||||
newKey.loading ||
|
||||
createPluginKeyMutation.isPending
|
||||
)
|
||||
return
|
||||
setNewKey((s) => ({ ...s, loading: true }))
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
}
|
||||
|
||||
const selectPluginSetupTab = (tab: PluginSetupTab) => {
|
||||
setPluginSetupTab(tab)
|
||||
if (tab === "manual") generatePluginKey()
|
||||
}
|
||||
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
const response = await $fetch("@post/connections/:provider", {
|
||||
|
|
@ -2824,14 +3032,22 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const handlePausedConnector = useCallback((provider: string) => {
|
||||
const pause = connectorPause(provider)
|
||||
if (!pause) return
|
||||
toast.info(pause.message)
|
||||
}, [])
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
async (planId?: unknown) => {
|
||||
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
||||
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 +3058,7 @@ export function IntegrationsView({
|
|||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
},
|
||||
[autumn],
|
||||
[autumn, promoCode],
|
||||
)
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
|
|
@ -2909,10 +3125,7 @@ export function IntegrationsView({
|
|||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
// Open instantly; the key fills in on mint. The ?connect param stays the source
|
||||
// of truth until the modal closes.
|
||||
setNewKey({ open: true, key: "", pluginId: target, loading: true })
|
||||
createPluginKeyMutation.mutate(target)
|
||||
openPluginSetup(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -2930,6 +3143,10 @@ export function IntegrationsView({
|
|||
if (["notion", "google-drive", "onedrive"].includes(target)) {
|
||||
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
|
||||
void setConnectTarget(null)
|
||||
if (connectorPause(target)) {
|
||||
handlePausedConnector(target)
|
||||
return
|
||||
}
|
||||
void setAddDoc("connect")
|
||||
}
|
||||
}, 0)
|
||||
|
|
@ -2947,8 +3164,9 @@ export function IntegrationsView({
|
|||
redirectToLogin,
|
||||
setConnectTarget,
|
||||
setAddDoc,
|
||||
createPluginKeyMutation,
|
||||
handleUpgrade,
|
||||
openPluginSetup,
|
||||
handlePausedConnector,
|
||||
])
|
||||
|
||||
const closeMcpModal = () => {
|
||||
|
|
@ -3263,7 +3481,7 @@ export function IntegrationsView({
|
|||
handleUpgrade("api_pro")
|
||||
return
|
||||
}
|
||||
createPluginKeyMutation.mutate("claude_code")
|
||||
openPluginSetup("claude_code")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -3341,7 +3559,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
className={cn(
|
||||
|
|
@ -3362,12 +3580,7 @@ export function IntegrationsView({
|
|||
<FinishSetupButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
|
||||
if (connectingPlugin) return
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
return
|
||||
}
|
||||
setFinishSetupPluginId(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
@ -3384,7 +3597,7 @@ export function IntegrationsView({
|
|||
<PillButton
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3402,15 +3615,20 @@ export function IntegrationsView({
|
|||
const count = connectionsByProvider[item.provider].length
|
||||
const isGranola = item.provider === "granola"
|
||||
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
|
||||
const pause = connectorPause(item.provider)
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another knowledge source"
|
||||
title="Add another knowledge source"
|
||||
aria-label={pause ? "Paused" : "Add another knowledge source"}
|
||||
title={pause ? pause.message : "Add another knowledge source"}
|
||||
onClick={() => {
|
||||
trackCard(item)
|
||||
if (pause) {
|
||||
handlePausedConnector(item.provider)
|
||||
return
|
||||
}
|
||||
if (isGranola) {
|
||||
if (!connectorAccess) {
|
||||
handleUpgrade("api_pro")
|
||||
|
|
@ -3424,13 +3642,27 @@ export function IntegrationsView({
|
|||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
pause && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{pause ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (pause) {
|
||||
return (
|
||||
<NotifyMeButton
|
||||
onClick={() => trackCard(item)}
|
||||
provider={item.provider}
|
||||
title={pause.message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (needsPlanUpgrade) {
|
||||
return (
|
||||
<PillButton onClick={() => handleUpgrade("api_pro")}>
|
||||
|
|
@ -3554,7 +3786,7 @@ export function IntegrationsView({
|
|||
return
|
||||
}
|
||||
trackCard(item)
|
||||
createPluginKeyMutation.mutate(item.pluginId)
|
||||
openPluginSetup(item.pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -3629,7 +3861,7 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const renderItemCard = (item: Item) => (
|
||||
const renderItemCard = (item: Item, layoutClassName?: string) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
actionSlot={renderRight(item)}
|
||||
|
|
@ -3645,6 +3877,8 @@ export function IntegrationsView({
|
|||
docsUrl={item.docsUrl}
|
||||
leftIndicator={renderLeftIndicator(item)}
|
||||
statusSlot={renderStatus(item)}
|
||||
layoutClassName={layoutClassName}
|
||||
paused={item.kind === "connector" && !!connectorPause(item.provider)}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
@ -3666,10 +3900,6 @@ export function IntegrationsView({
|
|||
!isAutumnLoading &&
|
||||
!hasProProduct &&
|
||||
!isFreeTierPlugin(connectedPluginId)
|
||||
const finishSetupPlugin = finishSetupPluginId
|
||||
? PLUGIN_CATALOG[finishSetupPluginId]
|
||||
: undefined
|
||||
const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
|
||||
const pluginSteps = dialogPlugin?.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
|
||||
|
|
@ -3754,7 +3984,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">
|
||||
|
|
@ -3767,6 +4004,23 @@ export function IntegrationsView({
|
|||
<SectionRail
|
||||
key={cat}
|
||||
label={CATEGORY_LABEL[cat]}
|
||||
labelSlot={
|
||||
cat === "plugins" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={pluginCommandsOpen}
|
||||
onClick={() => setPluginCommandsOpen(true)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center gap-1.5 rounded-full text-[10px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#4BA0FA]/60 sm:text-[11px]",
|
||||
)}
|
||||
>
|
||||
<span>Install plugins with one command</span>
|
||||
<NewChip />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
headerSlot={
|
||||
cat === "ai-clients" && activeMcpKey ? (
|
||||
<McpConnectedPill
|
||||
|
|
@ -3812,6 +4066,11 @@ export function IntegrationsView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<PluginCommandsDialog
|
||||
open={pluginCommandsOpen}
|
||||
onOpenChange={setPluginCommandsOpen}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={newKey.open}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -3821,7 +4080,10 @@ export function IntegrationsView({
|
|||
pluginId: open ? s.pluginId : null,
|
||||
loading: open ? s.loading : false,
|
||||
}))
|
||||
if (!open) void setConnectTarget(null)
|
||||
if (!open) {
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
|
|
@ -3854,9 +4116,11 @@ export function IntegrationsView({
|
|||
Set up {dialogPlugin?.name ?? "your plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
{newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Copy your key and run these steps to finish."}
|
||||
{pluginSetupTab === "agent"
|
||||
? "Copy this prompt into your coding agent."
|
||||
: newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Follow these steps to finish manually."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -3889,17 +4153,30 @@ export function IntegrationsView({
|
|||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
"min-w-0 space-y-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{newKey.loading ? (
|
||||
<PluginSetupMethodTabs
|
||||
value={pluginSetupTab}
|
||||
onChange={selectPluginSetupTab}
|
||||
/>
|
||||
{pluginSetupTab === "agent" && dialogPlugin ? (
|
||||
<PluginAgentInstructions plugin={dialogPlugin} />
|
||||
) : newKey.loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Generating your key…
|
||||
</div>
|
||||
) : (
|
||||
) : newKey.key ? (
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
We couldn't generate the key for the manual setup.
|
||||
</p>
|
||||
<PillButton onClick={generatePluginKey}>Try again</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3913,6 +4190,7 @@ export function IntegrationsView({
|
|||
pluginId: null,
|
||||
loading: false,
|
||||
})
|
||||
setPluginSetupTab("agent")
|
||||
void setConnectTarget(null)
|
||||
}}
|
||||
className={cn(
|
||||
|
|
@ -4051,7 +4329,7 @@ export function IntegrationsView({
|
|||
if (!connectedPluginId) return
|
||||
const pluginId = connectedPluginId
|
||||
setConnectedPluginId(null)
|
||||
createPluginKeyMutation.mutate(pluginId)
|
||||
openPluginSetup(pluginId)
|
||||
}}
|
||||
disabled={!!connectingPlugin}
|
||||
>
|
||||
|
|
@ -4082,91 +4360,6 @@ export function IntegrationsView({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!finishSetupPluginId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFinishSetupPluginId(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{finishSetupPlugin && (
|
||||
<IconBox>
|
||||
<Image
|
||||
src={finishSetupPlugin.icon}
|
||||
alt={finishSetupPlugin.name}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</IconBox>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Finish setup {finishSetupPlugin?.name ?? "plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
Complete install in the tool — this card turns active after the
|
||||
first API call.
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<X className="size-4 text-[#737373]" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{finishSetupSteps.length > 0 ? (
|
||||
<InstallSteps steps={finishSetupSteps} />
|
||||
) : (
|
||||
<p className="text-[13px] text-[#A1A1AA]">
|
||||
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
|
||||
authentication, then send a test memory.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
<Check className="size-3.5 text-[#4BA0FA]" /> Done
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={mcpModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -16,17 +16,22 @@ export function PillButton({
|
|||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: "button" | "submit"
|
||||
className?: string
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
|
|
@ -34,6 +39,7 @@ export function PillButton({
|
|||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { LogoFull } from "@ui/assets/Logo"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Input } from "@ui/components/input"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
ArrowRight,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
type ResearchEvent,
|
||||
|
|
@ -102,13 +104,19 @@ export function CompanyBrainOnboarding({
|
|||
setPhase("trial")
|
||||
analytics.brainTrialCardViewed()
|
||||
}, [needsSetup, phase])
|
||||
const { org } = useAuth()
|
||||
const [domain, setDomain] = useState(initialDomain)
|
||||
const [organizationChoices, setOrganizationChoices] = useState<
|
||||
CompanyBrainOrganizationChoice[] | null
|
||||
>(null)
|
||||
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
||||
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
||||
const clean = normalizeDomain(domain)
|
||||
// Returning from checkout remounts and reseeds local state from the email domain,
|
||||
// so past the confirm step the org's stored domain is the one to trust.
|
||||
const confirmedDomain = getBrainWorkspaceDomain(org?.metadata)
|
||||
const clean = normalizeDomain(
|
||||
phase === "confirm" ? domain : confirmedDomain || domain,
|
||||
)
|
||||
const queryClient = useQueryClient()
|
||||
const { status: researchStatus } = useResearchStatus(phase === "research")
|
||||
const researchDone = researchStatus === "done"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { useRouter } from "next/navigation"
|
||||
import { useResearchStatus } from "@/hooks/use-research-status"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { SetupCallButton } from "./setup-call-button"
|
||||
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
|
||||
|
||||
const BACKEND =
|
||||
|
|
@ -480,6 +481,14 @@ export function ResearchActionRail({
|
|||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-white/[0.06] pt-5">
|
||||
<p className="mb-3 text-[12px] font-medium leading-[1.5] text-[#525D6E]">
|
||||
Want us to wire it up live? Slack, connectors, plugins, and a
|
||||
working walkthrough.
|
||||
</p>
|
||||
<SetupCallButton className="w-full" surface="research_rail" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
32
apps/web/components/onboarding-brain/setup-call-button.tsx
Normal file
32
apps/web/components/onboarding-brain/setup-call-button.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { COMPANY_BRAIN_CAL_HREF, type SetupCallSurface } from "@/lib/cal"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export function SetupCallButton({
|
||||
className,
|
||||
surface,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
surface: SetupCallSurface
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={COMPANY_BRAIN_CAL_HREF}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => analytics.brainSetupCallClicked({ surface })}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-transparent px-4 py-2.5 text-[13px] font-medium text-[#E4E4E7] transition-colors hover:bg-white/[0.06] hover:text-[#FAFAFA]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? "Set up Company Brain with us"}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -80,8 +80,11 @@ import {
|
|||
} from "@lib/constants"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { toast } from "sonner"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
import { useConnectorNotify } from "@/lib/connector-notify"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
import { usePromoCode } from "@/hooks/use-promo-code"
|
||||
|
||||
type SourceId =
|
||||
| "drive"
|
||||
|
|
@ -149,11 +152,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",
|
||||
|
|
@ -377,6 +376,8 @@ export function StepSources({
|
|||
return !connectorAccess
|
||||
}
|
||||
|
||||
const notify = useConnectorNotify()
|
||||
|
||||
const setState = (id: SourceId, state: SourceState) => {
|
||||
onChange({ ...values, connected: { ...values.connected, [id]: state } })
|
||||
}
|
||||
|
|
@ -386,6 +387,11 @@ export function StepSources({
|
|||
id: SourceId,
|
||||
) => {
|
||||
analytics.onboardingIntegrationClicked({ integration: provider })
|
||||
if (connectorPause(provider)) {
|
||||
notify.request(provider)
|
||||
setState(id, "waitlist")
|
||||
return
|
||||
}
|
||||
setState(id, "connecting")
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
|
|
@ -425,12 +431,18 @@ export function StepSources({
|
|||
setState(id, "waitlist")
|
||||
}
|
||||
|
||||
// Paused beats locked: upgrading cannot unlock a connector nobody can connect.
|
||||
const guard = (
|
||||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => {
|
||||
return () => {
|
||||
if (provider && connectorPause(provider)) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
if (isLocked(plan) && plan) {
|
||||
setRequestedPlan(plan)
|
||||
setRequestedConnector(title)
|
||||
|
|
@ -618,6 +630,7 @@ function OnboardingPlansModal({
|
|||
requestedPlan: RequiredPlan
|
||||
}) {
|
||||
const autumn = useCustomer()
|
||||
const promoCode = usePromoCode()
|
||||
const { currentPlan, isLoading } = useTokenUsage(autumn)
|
||||
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
|
||||
null,
|
||||
|
|
@ -636,8 +649,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
|
||||
|
|
@ -948,19 +963,22 @@ function GoogleDriveSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
connectRealProvider: (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
) => void
|
||||
}) {
|
||||
const pause = connectorPause("google-drive")
|
||||
|
||||
return (
|
||||
<SourceCard
|
||||
title="Google Drive"
|
||||
blurb="Docs, sheets, slides — the working memory of your team."
|
||||
icon={<GoogleDrive className="size-7" />}
|
||||
state={values.connected.drive ?? "idle"}
|
||||
ctaLabel="Connect"
|
||||
ctaLabel={pause ? "Notify me" : "Connect"}
|
||||
locked={isLocked("pro")}
|
||||
requiredPlan="pro"
|
||||
perks={[
|
||||
|
|
@ -968,11 +986,19 @@ function GoogleDriveSourceCard({
|
|||
"Stays in sync as files change",
|
||||
"You pick what to share at sign-in",
|
||||
]}
|
||||
onConnect={guard("pro", "Google Drive", () =>
|
||||
connectRealProvider("google-drive", "drive"),
|
||||
onConnect={guard(
|
||||
"pro",
|
||||
"Google Drive",
|
||||
() => connectRealProvider("google-drive", "drive"),
|
||||
"google-drive",
|
||||
)}
|
||||
headerNote={
|
||||
values.driveScope === "full" ? (
|
||||
pause ? (
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#F5A524] font-medium">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
{pause.message}
|
||||
</p>
|
||||
) : values.driveScope === "full" ? (
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
|
||||
<AlertTriangle className="size-3 shrink-0" />
|
||||
Full Drive can exhaust your monthly usage.
|
||||
|
|
@ -1006,6 +1032,7 @@ function NotionSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
connectRealProvider: (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
|
|
@ -1048,6 +1075,7 @@ function GranolaSourceCard({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
|
|
@ -1092,6 +1120,7 @@ function MoreSourcesGrid({
|
|||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
fn: () => void,
|
||||
provider?: string,
|
||||
) => () => void
|
||||
openExternal: (id: SourceId, url: string) => void
|
||||
requestWaitlist: (id: SourceId) => void
|
||||
|
|
@ -1283,7 +1312,9 @@ function SourceCard({
|
|||
{isDone ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
|
||||
<Check className="size-3.5" />
|
||||
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
|
||||
{state === "waitlist"
|
||||
? "We'll email you"
|
||||
: (doneLabel ?? "Connected")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
|
|
@ -531,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(
|
||||
|
|
@ -698,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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -90,13 +90,6 @@ const ROWS: {
|
|||
effortHelp:
|
||||
"Deeper thinking routes messages more carefully but takes longer.",
|
||||
},
|
||||
{
|
||||
role: "research",
|
||||
effortKey: "researchEffort",
|
||||
title: "Web research",
|
||||
help: "Looks things up on the web when researching your company.",
|
||||
effortHelp: "Deeper research per web search, at the cost of speed.",
|
||||
},
|
||||
]
|
||||
|
||||
type FullConfig = Required<BrainModelConfig>
|
||||
|
|
@ -132,10 +125,8 @@ const PRESETS: PresetDef[] = [
|
|||
? "grok-4-fast"
|
||||
: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "low", "low"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "low", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "low", "low"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
|
@ -145,35 +136,24 @@ const PRESETS: PresetDef[] = [
|
|||
build: (defaults) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: defaults.mainEffort ?? "high",
|
||||
triageEffort: defaults.triageEffort ?? "low",
|
||||
researchEffort: defaults.researchEffort ?? "high",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "thorough",
|
||||
label: "Most thorough",
|
||||
description: "Deepest answers and research. Slower, uses more credits.",
|
||||
description: "Deepest answers. Slower, uses more credits.",
|
||||
build: (defaults, choices) => ({
|
||||
main: defaults.main,
|
||||
triage: defaults.triage,
|
||||
research: defaults.research,
|
||||
mainEffort: pickEffort(choices.mainEffort, "xhigh", "high"),
|
||||
triageEffort: pickEffort(choices.triageEffort, "medium", "low"),
|
||||
researchEffort: pickEffort(choices.researchEffort, "xhigh", "high"),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
"main",
|
||||
"triage",
|
||||
"research",
|
||||
"mainEffort",
|
||||
"triageEffort",
|
||||
"researchEffort",
|
||||
] as const
|
||||
const CONFIG_KEYS = ["main", "triage", "mainEffort", "triageEffort"] as const
|
||||
|
||||
const extraHighIsBounded = (model: string): boolean =>
|
||||
model.startsWith("grok-") || model.startsWith("gpt-")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
329
apps/web/components/settings/mcp-directory-browser.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"use client"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { McpDirectoryEntry } from "@/lib/mcp-directory"
|
||||
import { brainConnectorIcon } from "../brain-connector-icons"
|
||||
import { ConnectorCard, ScopeChip } from "../directory/connector-card"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
let directoryCache: McpDirectoryEntry[] | null = null
|
||||
|
||||
function isDirectoryEntry(value: unknown): value is McpDirectoryEntry {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const entry = value as Partial<McpDirectoryEntry>
|
||||
return (
|
||||
typeof entry.id === "string" &&
|
||||
typeof entry.name === "string" &&
|
||||
(entry.type === "remote" || entry.type === "local") &&
|
||||
(entry.url === null || typeof entry.url === "string") &&
|
||||
typeof entry.auth === "string" &&
|
||||
(entry.note === null || typeof entry.note === "string") &&
|
||||
Array.isArray(entry.categories) &&
|
||||
entry.categories.every((category) => typeof category === "string") &&
|
||||
typeof entry.popularity === "number" &&
|
||||
(entry.iconDomain === null || typeof entry.iconDomain === "string") &&
|
||||
["custom", "unsupported"].includes(entry.setup ?? "") &&
|
||||
(entry.oauthCapability === null ||
|
||||
["dcr", "preregistered"].includes(entry.oauthCapability ?? "")) &&
|
||||
Array.isArray(entry.authMethods) &&
|
||||
entry.authMethods.every((method) =>
|
||||
["oauth", "api-key"].includes(method),
|
||||
) &&
|
||||
["fixed", "tenant", "unavailable", "local"].includes(
|
||||
entry.availability ?? "",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parseDirectory(value: unknown) {
|
||||
if (!value || typeof value !== "object") throw new Error("invalid catalog")
|
||||
const entries = (value as { entries?: unknown }).entries
|
||||
if (!Array.isArray(entries) || !entries.every(isDirectoryEntry)) {
|
||||
throw new Error("invalid catalog")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
async function loadDirectory(signal: AbortSignal) {
|
||||
if (directoryCache) return directoryCache
|
||||
const response = await fetch(`${BACKEND}/brain/mcp-connections/directory`, {
|
||||
signal,
|
||||
cache: "default",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) throw new Error("catalog request failed")
|
||||
directoryCache = parseDirectory(await response.json())
|
||||
return directoryCache
|
||||
}
|
||||
|
||||
export function useMcpDirectory() {
|
||||
const [entries, setEntries] = useState<McpDirectoryEntry[]>(
|
||||
() => directoryCache ?? [],
|
||||
)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
void loadDirectory(controller.signal)
|
||||
.then((data) => {
|
||||
setEntries(data)
|
||||
setError(false)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return
|
||||
setError(true)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return { entries, error }
|
||||
}
|
||||
|
||||
export function categoryLabel(value: string) {
|
||||
return value
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function entrySlug(entry: McpDirectoryEntry) {
|
||||
return entry.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63)
|
||||
}
|
||||
|
||||
// Mirrors the backend's URL normalization so connection rows match entries.
|
||||
export function normalizeServerUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`.toLowerCase()
|
||||
} catch {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
// An entry we can actually take the user through connecting.
|
||||
export function isEntrySetUppable(entry: McpDirectoryEntry) {
|
||||
return (
|
||||
entry.setup !== "unsupported" &&
|
||||
entry.authMethods.length > 0 &&
|
||||
(entry.availability === "fixed" || entry.availability === "tenant")
|
||||
)
|
||||
}
|
||||
|
||||
// Entries worth listing at all — servers with no reachable URL are dropped.
|
||||
export function listableDirectoryEntries(entries: McpDirectoryEntry[]) {
|
||||
return entries.filter((entry) => entry.availability !== "unavailable")
|
||||
}
|
||||
|
||||
export function entryMatchesQuery(entry: McpDirectoryEntry, needle: string) {
|
||||
return [entry.name, entry.url, entry.note, ...entry.categories]
|
||||
.filter(Boolean)
|
||||
.some((value) => value?.toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
function DirectoryIcon({ entry }: { entry: McpDirectoryEntry }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (!entry.iconDomain || failed) {
|
||||
return brainConnectorIcon(entrySlug(entry), entry.name, "size-4")
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={`/api/mcp-icon?domain=${encodeURIComponent(entry.iconDomain)}`}
|
||||
alt=""
|
||||
className="size-5 object-contain"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DirectoryEntryCard({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
const status = connected
|
||||
? "Connected"
|
||||
: canSetUp
|
||||
? "Not connected"
|
||||
: entry.availability === "local"
|
||||
? "Desktop only"
|
||||
: "Coming soon"
|
||||
return (
|
||||
<ConnectorCard
|
||||
icon={<DirectoryIcon entry={entry} />}
|
||||
name={entry.name}
|
||||
subtitle={entrySubtitle(entry)}
|
||||
footerLeft={<ScopeChip label={status} connected={connected} />}
|
||||
footerRight={
|
||||
canSetUp ? (
|
||||
<PillButton onClick={() => onSetUp(entry)}>Set up</PillButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function entrySubtitle(entry: McpDirectoryEntry) {
|
||||
if (entry.categories.length > 0) {
|
||||
return entry.categories.slice(0, 2).map(categoryLabel).join(" · ")
|
||||
}
|
||||
return entry.type === "local" ? "Desktop extension" : "MCP server"
|
||||
}
|
||||
|
||||
// One directory listing: a dense single-line row. The default state carries no
|
||||
// status text — in a marketplace, "not connected" is implied. Only connection,
|
||||
// or the reason there's no button, earns words.
|
||||
export function DirectoryEntryRow({
|
||||
entry,
|
||||
connected,
|
||||
onSetUp,
|
||||
}: {
|
||||
entry: McpDirectoryEntry
|
||||
connected: boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
}) {
|
||||
const canSetUp = !connected && isEntrySetUppable(entry)
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[#14161A]">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
<DirectoryIcon entry={entry} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-[#FAFAFA]">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="mt-px truncate text-[11px] font-medium text-[#616875]">
|
||||
{entrySubtitle(entry)}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 pr-1 text-[11px] font-medium text-[#FAFAFA]">
|
||||
<span className="size-[6px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : canSetUp ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetUp(entry)}
|
||||
className="h-7 shrink-0 cursor-pointer rounded-full bg-[#1B2028] px-3 text-[12px] font-medium text-[#FAFAFA]/70 transition-colors group-hover:bg-[#252C37] group-hover:text-[#FAFAFA] hover:bg-[#2B3340]"
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 pr-1 text-[11px] font-medium text-[#4E5560]">
|
||||
{entry.availability === "local" ? "Desktop only" : "Coming soon"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GRID_PAGE_SIZE = 24
|
||||
|
||||
// Paged card grid over the MCP directory. With a query it renders matching
|
||||
// servers; without one it renders the whole marketplace.
|
||||
export function McpDirectoryGrid({
|
||||
query = "",
|
||||
entries,
|
||||
loadError,
|
||||
excludeSlugs,
|
||||
isEntryConnected,
|
||||
onSetUp,
|
||||
suppressEmpty,
|
||||
}: {
|
||||
query?: string
|
||||
entries: McpDirectoryEntry[]
|
||||
loadError: boolean
|
||||
// entries already rendered elsewhere (e.g. the built-in app catalog)
|
||||
excludeSlugs?: Set<string>
|
||||
isEntryConnected: (entry: McpDirectoryEntry) => boolean
|
||||
onSetUp: (entry: McpDirectoryEntry) => void
|
||||
// the caller rendered its own matches, so an empty grid isn't "no results"
|
||||
suppressEmpty?: boolean
|
||||
}) {
|
||||
const [visibleCount, setVisibleCount] = useState(GRID_PAGE_SIZE)
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset paging per query
|
||||
useEffect(() => {
|
||||
setVisibleCount(GRID_PAGE_SIZE)
|
||||
}, [needle])
|
||||
|
||||
// Connected first, then connectable, then "coming soon"/desktop-only.
|
||||
const matches = useMemo(() => {
|
||||
const found = entries.filter(
|
||||
(entry) =>
|
||||
!excludeSlugs?.has(entrySlug(entry)) &&
|
||||
(!needle || entryMatchesQuery(entry, needle)),
|
||||
)
|
||||
return found.sort(
|
||||
(a, b) =>
|
||||
Number(isEntryConnected(b)) - Number(isEntryConnected(a)) ||
|
||||
Number(isEntrySetUppable(b)) - Number(isEntrySetUppable(a)),
|
||||
)
|
||||
}, [entries, excludeSlugs, isEntryConnected, needle])
|
||||
|
||||
if (loadError) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
The MCP directory couldn't be loaded. Refresh to try again.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-[13px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading MCP directory
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
if (suppressEmpty) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-[#252B34] border-dashed px-4 py-10 text-center text-[13px] font-medium text-[#737373]">
|
||||
No integrations match “{query.trim()}”.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{matches.slice(0, visibleCount).map((entry) => (
|
||||
<DirectoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
connected={isEntryConnected(entry)}
|
||||
onSetUp={onSetUp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < matches.length ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + GRID_PAGE_SIZE)}
|
||||
className="mx-auto flex h-9 cursor-pointer items-center rounded-full border border-[#2A313C] px-5 text-[12px] font-semibold text-[#D4D4D8] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]"
|
||||
>
|
||||
Show {Math.min(GRID_PAGE_SIZE, matches.length - visibleCount)} more ·{" "}
|
||||
{visibleCount} of {matches.length.toLocaleString()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/web/components/setup-call-link.tsx
Normal file
45
apps/web/components/setup-call-link.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { CalendarClock } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { cn } from "@lib/utils"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
|
||||
import { useTrialStatus } from "@/hooks/use-trial-status"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function SetupCallLink() {
|
||||
const { data } = useTrialStatus()
|
||||
if (!data?.active) return null
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Book a setup call"
|
||||
>
|
||||
<a
|
||||
href={COMPANY_BRAIN_CAL_HREF}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() =>
|
||||
analytics.brainSetupCallClicked({ surface: "header" })
|
||||
}
|
||||
>
|
||||
<CalendarClock className="size-4 shrink-0" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Book a setup call
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { authClient } from "@lib/auth"
|
|||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Brain,
|
||||
CalendarClock,
|
||||
LogOut,
|
||||
Settings,
|
||||
Settings2,
|
||||
|
|
@ -29,6 +30,7 @@ import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
|||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { COMPANY_BRAIN_CAL_HREF } from "@/lib/cal"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
|
||||
export function UserProfileMenu({
|
||||
|
|
@ -213,6 +215,24 @@ export function UserProfileMenu({
|
|||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
{isCompanyBrain ? (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<a
|
||||
href={COMPANY_BRAIN_CAL_HREF}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() =>
|
||||
analytics.brainSetupCallClicked({ surface: "user_menu" })
|
||||
}
|
||||
>
|
||||
<CalendarClock className="size-4 text-[#737373]" />
|
||||
Book a setup call
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ const BACKEND =
|
|||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const BASE = `${BACKEND}/brain/models`
|
||||
|
||||
export type BrainModelRole = "main" | "triage" | "research"
|
||||
export type BrainModelRole = "main" | "triage"
|
||||
export type BrainReasoningEffort = "auto" | "low" | "medium" | "high" | "xhigh"
|
||||
export type BrainReasoningKey = "mainEffort" | "triageEffort" | "researchEffort"
|
||||
export type BrainReasoningKey = "mainEffort" | "triageEffort"
|
||||
|
||||
export type BrainModelConfig = Record<BrainModelRole, string> &
|
||||
Partial<Record<BrainReasoningKey, BrainReasoningEffort>>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
getBrainMode,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "@/lib/billing-utils"
|
||||
import { isCompanyBrainOrg } from "@/lib/billing-utils"
|
||||
|
||||
export function useHasCompanyBrain(): boolean {
|
||||
const { org } = useAuth()
|
||||
const metadata = org?.metadata as Record<string, unknown> | string | undefined
|
||||
// An explicit concierge override wins over the team-onboarding fallback.
|
||||
const override = getCompanyBrainOverride(metadata)
|
||||
if (override !== undefined) return override
|
||||
// Team-brain orgs use brain spaces even before the add-on webhook lands.
|
||||
return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
|
||||
return isCompanyBrainOrg(metadata)
|
||||
}
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import posthog from "posthog-js"
|
||||
import type { BrainStep } from "@/components/onboarding-brain/types"
|
||||
import type { SetupCallSurface } from "@/lib/cal"
|
||||
|
||||
const pendingEvents: Array<{
|
||||
eventName: string
|
||||
|
|
@ -83,6 +84,8 @@ export const analytics = {
|
|||
connectionDeleted: () => safeCapture("connection_deleted"),
|
||||
connectionAuthStarted: (props: { provider: string }) =>
|
||||
safeCapture("connection_auth_started", props),
|
||||
connectorPausedClicked: (props: { provider: string; reason: string }) =>
|
||||
safeCapture("connector_paused_clicked", props),
|
||||
|
||||
// integrations surface (main Nova page)
|
||||
integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
|
||||
|
|
@ -276,4 +279,9 @@ export const analytics = {
|
|||
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
|
||||
brainTrialCheckoutAbandoned: () =>
|
||||
safeCapture("brain_trial_checkout_abandoned"),
|
||||
brainSetupCallClicked: (props: { surface: SetupCallSurface }) =>
|
||||
safeCapture("brain_setup_call_clicked", props),
|
||||
brainSetupModalSeen: () => safeCapture("brain_setup_modal_seen"),
|
||||
brainSetupModalPicked: (props: { choice: "slack" | "call" }) =>
|
||||
safeCapture("brain_setup_modal_picked", props),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,15 @@ export function getBrainMode(
|
|||
: null
|
||||
}
|
||||
|
||||
export function isCompanyBrainOrg(
|
||||
metadataRaw: Record<string, unknown> | string | null | undefined,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(metadataRaw)
|
||||
if (override !== undefined) return override
|
||||
if (hasCompanyBrain(metadataRaw)) return true
|
||||
return getBrainMode(metadataRaw) === "team"
|
||||
}
|
||||
|
||||
export type BrainTrialStatus =
|
||||
| "active"
|
||||
| "exhausted"
|
||||
|
|
|
|||
8
apps/web/lib/cal.ts
Normal file
8
apps/web/lib/cal.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const COMPANY_BRAIN_CAL_HREF =
|
||||
"https://cal.com/team/supermemory/company-brain"
|
||||
|
||||
export type SetupCallSurface =
|
||||
| "research_rail"
|
||||
| "setup_modal"
|
||||
| "header"
|
||||
| "user_menu"
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
getBrainMode,
|
||||
getBrainWorkspaceDomain,
|
||||
getCompanyBrainOverride,
|
||||
hasCompanyBrain,
|
||||
} from "./billing-utils"
|
||||
import { getBrainWorkspaceDomain, isCompanyBrainOrg } from "./billing-utils"
|
||||
|
||||
export type BrainEntryOrganization = {
|
||||
id: string
|
||||
|
|
@ -18,21 +13,12 @@ export type CompanyBrainEntryDecision =
|
|||
| { action: "choose"; organizations: BrainEntryOrganization[] }
|
||||
| { action: "create" }
|
||||
|
||||
export function isCompanyBrainOrganization(
|
||||
organization: BrainEntryOrganization,
|
||||
): boolean {
|
||||
const override = getCompanyBrainOverride(organization.metadata)
|
||||
if (override !== undefined) return override
|
||||
return (
|
||||
hasCompanyBrain(organization.metadata) ||
|
||||
getBrainMode(organization.metadata) === "team"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCompanyBrainOrganizations(
|
||||
organizations: BrainEntryOrganization[],
|
||||
): BrainEntryOrganization[] {
|
||||
return organizations.filter(isCompanyBrainOrganization)
|
||||
return organizations.filter((organization) =>
|
||||
isCompanyBrainOrg(organization.metadata),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDomain(domain: string): string {
|
||||
|
|
|
|||
30
apps/web/lib/connector-availability.ts
Normal file
30
apps/web/lib/connector-availability.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type ConnectorPauseReason = "google_verification"
|
||||
|
||||
export interface ConnectorPause {
|
||||
reason: ConnectorPauseReason
|
||||
label: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const PAUSED_CONNECTORS: Record<string, ConnectorPause> = {
|
||||
"google-drive": {
|
||||
reason: "google_verification",
|
||||
label: "Google Drive",
|
||||
message:
|
||||
"New Google Drive connections are paused while Google reviews our app. Already connected? Your files keep syncing.",
|
||||
},
|
||||
gmail: {
|
||||
reason: "google_verification",
|
||||
label: "Gmail",
|
||||
message:
|
||||
"New Gmail connections are paused while Google reviews our app. Already connected? Your email keeps syncing.",
|
||||
},
|
||||
}
|
||||
|
||||
export function connectorPause(provider: string): ConnectorPause | undefined {
|
||||
return PAUSED_CONNECTORS[provider]
|
||||
}
|
||||
|
||||
export function isConnectorPaused(provider: string): boolean {
|
||||
return provider in PAUSED_CONNECTORS
|
||||
}
|
||||
44
apps/web/lib/connector-notify.ts
Normal file
44
apps/web/lib/connector-notify.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { connectorPause } from "@/lib/connector-availability"
|
||||
|
||||
const key = (provider: string) => `connector_notify:${provider}`
|
||||
|
||||
// Per-browser only. The PostHog event is the record of truth for who to email.
|
||||
export function useConnectorNotify() {
|
||||
const [requested, setRequested] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const seen: Record<string, boolean> = {}
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (k?.startsWith("connector_notify:")) {
|
||||
seen[k.slice("connector_notify:".length)] = true
|
||||
}
|
||||
}
|
||||
setRequested(seen)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const isRequested = useCallback(
|
||||
(provider: string) => requested[provider] === true,
|
||||
[requested],
|
||||
)
|
||||
|
||||
const request = useCallback((provider: string) => {
|
||||
const pause = connectorPause(provider)
|
||||
if (!pause) return
|
||||
analytics.connectorPausedClicked({ provider, reason: pause.reason })
|
||||
try {
|
||||
localStorage.setItem(key(provider), "1")
|
||||
} catch {}
|
||||
setRequested((prev) => ({ ...prev, [provider]: true }))
|
||||
toast.success(`We'll email you when ${pause.label} is back.`)
|
||||
}, [])
|
||||
|
||||
return { isRequested, request }
|
||||
}
|
||||
21
apps/web/lib/mcp-directory.ts
Normal file
21
apps/web/lib/mcp-directory.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type McpDirectoryAvailability =
|
||||
| "fixed"
|
||||
| "tenant"
|
||||
| "unavailable"
|
||||
| "local"
|
||||
|
||||
export type McpDirectoryEntry = {
|
||||
id: string
|
||||
name: string
|
||||
type: "remote" | "local"
|
||||
url: string | null
|
||||
auth: string
|
||||
note: string | null
|
||||
categories: string[]
|
||||
popularity: number
|
||||
availability: McpDirectoryAvailability
|
||||
iconDomain: string | null
|
||||
setup: "custom" | "unsupported"
|
||||
oauthCapability: "dcr" | "preregistered" | null
|
||||
authMethods: Array<"oauth" | "api-key">
|
||||
}
|
||||
518
apps/web/lib/mcp-icon-domains.json
Normal file
518
apps/web/lib/mcp-icon-domains.json
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
{
|
||||
"domains": [
|
||||
"10xgenomics.com",
|
||||
"activecampaign.com",
|
||||
"actively.ai",
|
||||
"adisinsight-mcp.springer.com",
|
||||
"adobe-creativity.adobe.io",
|
||||
"adobeaemcloud.com",
|
||||
"aep-ai-ama.adobe.io",
|
||||
"affinity.co",
|
||||
"aftership.com",
|
||||
"agent.thoughtspot.app",
|
||||
"agentmail.to",
|
||||
"agents.riskanalytics.dnb.com",
|
||||
"agenttools.wolfram.com",
|
||||
"ahrefs.com",
|
||||
"ai-connect.norton.com",
|
||||
"ai-inc.mailchimp.com",
|
||||
"ai-inc.quickbooks.intuit.com",
|
||||
"ai-inc.turbotax.intuit.com",
|
||||
"ai-tools.tillermoney.com",
|
||||
"ai.chronograph.pe",
|
||||
"ai.consilio.com",
|
||||
"ai.thirdbridge.com",
|
||||
"ai.todoist.net",
|
||||
"ai.veltra.com",
|
||||
"airbnb.com",
|
||||
"airtable.com",
|
||||
"ajo-mcp.adobe.io",
|
||||
"alltrails.com",
|
||||
"alma.food",
|
||||
"alphavantage.co",
|
||||
"alphaxiv.org",
|
||||
"alpic.ai",
|
||||
"amplitude.com",
|
||||
"analytics.credit.morningstar.com",
|
||||
"analytics.lseg.com",
|
||||
"android.com",
|
||||
"angellist.com",
|
||||
"anthropic.mcp.creditkarma.com",
|
||||
"api-ssl.bitly.com",
|
||||
"apify.com",
|
||||
"apigw.americanexpress.com",
|
||||
"apollo.io",
|
||||
"apollographql.com",
|
||||
"app.airops.com",
|
||||
"app.base44.com",
|
||||
"app.brighthire.ai",
|
||||
"app.carta.com",
|
||||
"app.definely.com",
|
||||
"app.eraser.io",
|
||||
"app.files.com",
|
||||
"app.flourish.studio",
|
||||
"app.fyxer.com",
|
||||
"app.grasp-ai.com",
|
||||
"app.hanoverpark.com",
|
||||
"app.ketryx.com",
|
||||
"app.magicschool.ai",
|
||||
"app.midpage.ai",
|
||||
"app.synthesize.bio",
|
||||
"app.tropicapp.io",
|
||||
"app.unthread.io",
|
||||
"appfolio.com",
|
||||
"asana.com",
|
||||
"ashbyhq.com",
|
||||
"asset-management.mcp.cloudinary.com",
|
||||
"atlassian.com",
|
||||
"attention.tech",
|
||||
"attio.com",
|
||||
"audible.com",
|
||||
"auraintelligence.com",
|
||||
"autodesk.com",
|
||||
"autorfp.ai",
|
||||
"benchling.com",
|
||||
"benevity.org",
|
||||
"bigdata.com",
|
||||
"bigquery.googleapis.com",
|
||||
"bindings.mcp.cloudflare.com",
|
||||
"blockscout.com",
|
||||
"blueconic.com",
|
||||
"boltz.bio",
|
||||
"box.com",
|
||||
"brandfetch.io",
|
||||
"brave.com",
|
||||
"braze.com",
|
||||
"brevo.com",
|
||||
"brex.com",
|
||||
"briskteaching.com",
|
||||
"calendar.google.com",
|
||||
"calendly.com",
|
||||
"callbacks.omniapp.co",
|
||||
"canary-data.com",
|
||||
"candid.org",
|
||||
"canva.com",
|
||||
"cargoai.co",
|
||||
"cbinsights.com",
|
||||
"chargebee.com",
|
||||
"chartmogul.com",
|
||||
"chatgpt.mermaid.ai",
|
||||
"checkatrade.com",
|
||||
"circleback.ai",
|
||||
"civitatis-claude-app.civitatis.com",
|
||||
"cja-mcp.adobe.io",
|
||||
"clapi.guidepoint.io",
|
||||
"clarify.ai",
|
||||
"clarity-sfdr20-mcp.pro.clarity.ai",
|
||||
"claude-mcp-api.ml.goodnotes.com",
|
||||
"claude.mcp.kpler.com",
|
||||
"claude.slidesgpt.com",
|
||||
"claudecompanion.gateway.api.mcafee.com",
|
||||
"clay.com",
|
||||
"clerk.com",
|
||||
"clickhouse.cloud",
|
||||
"clickup.com",
|
||||
"close.com",
|
||||
"cloud.cdata.com",
|
||||
"cloudimanage.com",
|
||||
"cloze.com",
|
||||
"cognitoforms.com",
|
||||
"coindesk.com",
|
||||
"columnapi.com",
|
||||
"cometchat.com",
|
||||
"commonroom.io",
|
||||
"compute.googleapis.com",
|
||||
"connect.squareup.com",
|
||||
"connector.scholargateway.ai",
|
||||
"consensus.app",
|
||||
"contentsquare.com",
|
||||
"context.era.app",
|
||||
"context7.com",
|
||||
"coralogix.com",
|
||||
"coteach.ai",
|
||||
"coupler.io",
|
||||
"coursera.com",
|
||||
"courtlistener.com",
|
||||
"courtroom5.com",
|
||||
"craft.do",
|
||||
"crossbeam.com",
|
||||
"crypto.com",
|
||||
"customer.io",
|
||||
"daloopa.com",
|
||||
"dashboard.plaid.com",
|
||||
"data-search.apigw.feverup.com",
|
||||
"databricks.com",
|
||||
"datacamp.com",
|
||||
"datadoghq.com",
|
||||
"datagrail.io",
|
||||
"datahub.com",
|
||||
"day.ai",
|
||||
"deepl.com",
|
||||
"demandapi-mcp.booking.com",
|
||||
"descript.com",
|
||||
"descrybe.com",
|
||||
"developer.api.autodesk.com",
|
||||
"developer.mcp.mastercard.com",
|
||||
"devrev.ai",
|
||||
"dhsprogram.com",
|
||||
"dice.com",
|
||||
"diffit.me",
|
||||
"digits.com",
|
||||
"directbooker.ai",
|
||||
"docs.superhuman.com",
|
||||
"docuseal.com",
|
||||
"docusign.com",
|
||||
"dovetail.com",
|
||||
"dremio.com",
|
||||
"drive.google.com",
|
||||
"dropbox.com",
|
||||
"dynatrace.com",
|
||||
"econ-index.mcp.claude.com",
|
||||
"elevenlabs.io",
|
||||
"elicit.com",
|
||||
"entendre.finance",
|
||||
"eulerapp.com",
|
||||
"everlaw.com",
|
||||
"exa.ai",
|
||||
"example-server.modelcontextprotocol.io",
|
||||
"excalidraw.com",
|
||||
"exp-app-mcp.prod.ep.viator.com",
|
||||
"expedia.com",
|
||||
"expo.dev",
|
||||
"factset.com",
|
||||
"fathom.ai",
|
||||
"fellow.app",
|
||||
"felt.com",
|
||||
"fids-mcp.ice.com",
|
||||
"fig-mcp.instacart.com",
|
||||
"figma.com",
|
||||
"financeanalytics.dnb.com",
|
||||
"financialmodelingprep.com",
|
||||
"fireflies.ai",
|
||||
"firefox.com",
|
||||
"fiscal.ai",
|
||||
"fitch.group",
|
||||
"floot.com",
|
||||
"frontify-integrations.com",
|
||||
"fullstory.com",
|
||||
"funnel.io",
|
||||
"g.runorion.com",
|
||||
"g2.com",
|
||||
"gainsight.com",
|
||||
"gamma.app",
|
||||
"gatewaymcp.verisk.com",
|
||||
"genai-prod-ext.dominos.co.in",
|
||||
"getaugust.ai",
|
||||
"getguru.com",
|
||||
"getmontecarlo.com",
|
||||
"getunblocked.com",
|
||||
"glean.com",
|
||||
"global.datasite.com",
|
||||
"glovoapp.com",
|
||||
"gmail.com",
|
||||
"gocardless.com",
|
||||
"godaddy.com",
|
||||
"gopigment.com",
|
||||
"govcon.dev",
|
||||
"govtribe.com",
|
||||
"grain.com",
|
||||
"granola.ai",
|
||||
"grantedai.com",
|
||||
"grasshopper-mcp.prd.narmitech.com",
|
||||
"grounding.kensho.com",
|
||||
"gusto.com",
|
||||
"harmonic.ai",
|
||||
"harness.io",
|
||||
"harvey.ai",
|
||||
"haveibeenpwned.com",
|
||||
"hcls.mcp.claude.com",
|
||||
"healthex.io",
|
||||
"helium10.com",
|
||||
"heygen.com",
|
||||
"highspot.com",
|
||||
"honeycomb.io",
|
||||
"hrn-production.helix.com",
|
||||
"hubspot.com",
|
||||
"huggingface.co",
|
||||
"ibisworld.com",
|
||||
"ibkr.com",
|
||||
"idiolect.app",
|
||||
"ifttt.com",
|
||||
"imedidata.com",
|
||||
"incident.io",
|
||||
"indeed.com",
|
||||
"inductive.bio",
|
||||
"inkbox.ai",
|
||||
"insiderone.com",
|
||||
"instrumentl.com",
|
||||
"intapp.com",
|
||||
"integrators.prod.api.tabsplatform.com",
|
||||
"intercom.com",
|
||||
"ipone.clarivate.com",
|
||||
"ironcladapp.com",
|
||||
"isometric.com",
|
||||
"item.app",
|
||||
"jam.dev",
|
||||
"jentic.com",
|
||||
"jotform.com",
|
||||
"jupiterone.com",
|
||||
"jusmundi.com",
|
||||
"k.owkin.com",
|
||||
"kfinance.kensho.com",
|
||||
"kg.mcp.learningcommons.org",
|
||||
"kindora-mcp.azurewebsites.net",
|
||||
"kiwi.com",
|
||||
"klaviyo.com",
|
||||
"krisp.ai",
|
||||
"kubernetes.io",
|
||||
"lastminute.com",
|
||||
"latch.bio",
|
||||
"latticehq.com",
|
||||
"lawve.ai",
|
||||
"learn.microsoft.com",
|
||||
"leaveadot.com",
|
||||
"legal-mcp.thomsonreuters.com",
|
||||
"legaldatahunter.com",
|
||||
"legalzoom.com",
|
||||
"letsbot.net",
|
||||
"letsdeel.com",
|
||||
"light.inc",
|
||||
"lightfield.app",
|
||||
"lilt.com",
|
||||
"linear.app",
|
||||
"listenlabs.ai",
|
||||
"litmus.com",
|
||||
"livestorm.co",
|
||||
"localfalcon.com",
|
||||
"lorikeetcx.ai",
|
||||
"lovable.dev",
|
||||
"lucid.app",
|
||||
"luminpdf.com",
|
||||
"lumonic.com",
|
||||
"lunarcrush.ai",
|
||||
"lusha.com",
|
||||
"macaly.com",
|
||||
"magicpatterns.com",
|
||||
"mail.superhuman.com",
|
||||
"mailerlite.com",
|
||||
"make.com",
|
||||
"manufact.com",
|
||||
"marketplace-mcp.us-east-1.api.aws",
|
||||
"matrixmcp.virtuoso.ai",
|
||||
"mcp-app.turkishtechlab.com",
|
||||
"mcp-demo.airwallex.com",
|
||||
"mcp-gateway-external-pilot.spotify.net",
|
||||
"mcp-pub.aiera.com",
|
||||
"mcp-public.basecamp-research.com",
|
||||
"mcp-server.egnyte.com",
|
||||
"mcp-server.signnow.com",
|
||||
"mcp-server.zomato.com",
|
||||
"mcp-v1.tixel.com",
|
||||
"mcp2.readwise.io",
|
||||
"meetcampfire.com",
|
||||
"melon.com",
|
||||
"meltwater.com",
|
||||
"mem.ai",
|
||||
"mem0.ai",
|
||||
"mercadolibre.com",
|
||||
"mercury.com",
|
||||
"metabase.com",
|
||||
"metal.ai",
|
||||
"metaview.ai",
|
||||
"microsoft.com",
|
||||
"mintlify.com",
|
||||
"miro.com",
|
||||
"mixpanel.com",
|
||||
"monday.com",
|
||||
"mongodb.com",
|
||||
"moodys.com",
|
||||
"morningstar.com",
|
||||
"mospi.gov.in",
|
||||
"motherduck.com",
|
||||
"msci.com",
|
||||
"mtnewswires.com",
|
||||
"myisolved.com",
|
||||
"n8n.io",
|
||||
"netlify-mcp.netlify.app",
|
||||
"netsuite.com",
|
||||
"nimbleway.com",
|
||||
"nlp.api.production.unwrap.ai",
|
||||
"nooks.in",
|
||||
"notion.com",
|
||||
"omni.mulesoft.com",
|
||||
"onesignal.com",
|
||||
"ontra.ai",
|
||||
"open-ai-app.stubhub.net",
|
||||
"oreilly.com",
|
||||
"otter.ai",
|
||||
"ottotheagent.com",
|
||||
"outreach.io",
|
||||
"pagerduty.com",
|
||||
"pandadoc.com",
|
||||
"partner-mcp.ticketmaster.com",
|
||||
"patlytics.ai",
|
||||
"paypal.com",
|
||||
"paytmpayments.com",
|
||||
"peec.ai",
|
||||
"pga.com",
|
||||
"phished.io",
|
||||
"phoenix.hginsights.com",
|
||||
"pi.security",
|
||||
"pinegap.ai",
|
||||
"platform.opentargets.org",
|
||||
"plaud.ai",
|
||||
"playmcp.kakao.com",
|
||||
"polaranalytics.com",
|
||||
"pophive.org",
|
||||
"posthog.com",
|
||||
"postman.com",
|
||||
"premium.mcp.pitchbook.com",
|
||||
"privacy.com",
|
||||
"process.st",
|
||||
"prod.originhq.com",
|
||||
"production.ai-mcp-extensibility-prd.tamg.cloud",
|
||||
"projects.motionapp.com",
|
||||
"pscale.dev",
|
||||
"public-api.wordpress.com",
|
||||
"pubmed.mcp.claude.com",
|
||||
"qbo-connector.meridian.pilot.com",
|
||||
"qonto.com",
|
||||
"quartr.com",
|
||||
"quicknode.com",
|
||||
"quo.com",
|
||||
"railway.com",
|
||||
"rallyuxr.com",
|
||||
"ramp-mcp-remote.ramp.com",
|
||||
"ramp.com",
|
||||
"rapid7.com",
|
||||
"razorpay.com",
|
||||
"react.dev",
|
||||
"read.ai",
|
||||
"reclaim.ai",
|
||||
"reddit.com",
|
||||
"relativity.com",
|
||||
"remote.com",
|
||||
"render.com",
|
||||
"replit-mcp.com",
|
||||
"resend.com",
|
||||
"retool.com",
|
||||
"revolut.com",
|
||||
"rillet.com",
|
||||
"roamresearch.com",
|
||||
"roboflow.com",
|
||||
"salesflare.com",
|
||||
"salesloft.com",
|
||||
"sanity.io",
|
||||
"sap.com",
|
||||
"scamguard.malwarebytes.com",
|
||||
"scite.ai",
|
||||
"seismic.com",
|
||||
"semrush.com",
|
||||
"send.co",
|
||||
"sentry.dev",
|
||||
"servicenow.com",
|
||||
"services.biorender.com",
|
||||
"services.functionhealth.com",
|
||||
"services.oxfordeconomics.com",
|
||||
"setup.shopify.com",
|
||||
"shapes.co",
|
||||
"shipbob.com",
|
||||
"shippo.com",
|
||||
"shutterstock.com",
|
||||
"sigmacomputing.com",
|
||||
"signeasy.com",
|
||||
"similarweb.com",
|
||||
"sketch.com",
|
||||
"sketchup.com",
|
||||
"slack.com",
|
||||
"smartbear.com",
|
||||
"smartling.com",
|
||||
"smartsheet.com",
|
||||
"snowflake.com",
|
||||
"snowstorm-mcp.snomedtools.org",
|
||||
"snyk.io",
|
||||
"solveintelligence.com",
|
||||
"sourcegraph.com",
|
||||
"spinach.ai",
|
||||
"splice.com",
|
||||
"sprouts-mcp-server.kartikay-dhar.workers.dev",
|
||||
"squareup.com",
|
||||
"stackoverflow.com",
|
||||
"staircase.ai",
|
||||
"starburst.io",
|
||||
"strava.com",
|
||||
"stripe.com",
|
||||
"stytch.dev",
|
||||
"sumble.com",
|
||||
"sumsub.com",
|
||||
"supabase.com",
|
||||
"super.com",
|
||||
"supermetrics.com",
|
||||
"surveymonkey.com",
|
||||
"swagger.mcp.smartbear.com",
|
||||
"sybill.ai",
|
||||
"synapse.org",
|
||||
"tableau.com",
|
||||
"taskrabbit.com",
|
||||
"tavily.com",
|
||||
"taxact.com",
|
||||
"teacher-tools.eedi.ai",
|
||||
"teamtailor.com",
|
||||
"techgc.co",
|
||||
"tellme.embat.io",
|
||||
"thumbtack.com",
|
||||
"tickettailor.ai",
|
||||
"ticktick.com",
|
||||
"tigerdata.com",
|
||||
"tines.com",
|
||||
"tldraw-mcp-app.tldraw.workers.dev",
|
||||
"tldv.io",
|
||||
"tomtom.com",
|
||||
"tray.io",
|
||||
"trellis.law",
|
||||
"trello.com",
|
||||
"trivago.com",
|
||||
"tryprofound.com",
|
||||
"turquoise.health",
|
||||
"twilio.com",
|
||||
"uakozrqrztgrgwoywxkx.supabase.co",
|
||||
"uber.com",
|
||||
"ubereats.com",
|
||||
"udemy.com",
|
||||
"unsplash.com",
|
||||
"use.kick.co",
|
||||
"usepylon.com",
|
||||
"v0.app",
|
||||
"vast.blueskyapi.com",
|
||||
"vendr.com",
|
||||
"vercel.com",
|
||||
"vibe.com",
|
||||
"virtuoso.ai",
|
||||
"voluum.com",
|
||||
"webexapis.com",
|
||||
"webflow.com",
|
||||
"webull.com",
|
||||
"whimsical.com",
|
||||
"windsor.ai",
|
||||
"wisdom-api.enterpret.com",
|
||||
"wisprflow.ai",
|
||||
"within.ai",
|
||||
"wix.com",
|
||||
"workable.com",
|
||||
"workato.com",
|
||||
"workfront.adobe.com",
|
||||
"workos.com",
|
||||
"wrike.com",
|
||||
"wyndhamhotels.com",
|
||||
"xactrestore-xactremodelserver-usw2-prod.propsol.io",
|
||||
"xero.com",
|
||||
"xweather.com",
|
||||
"zapier.com",
|
||||
"ziprecruiter.com",
|
||||
"zocks.io",
|
||||
"zoho.com",
|
||||
"zoom.us",
|
||||
"zoominfo.com",
|
||||
"zscaler.com"
|
||||
]
|
||||
}
|
||||
47
apps/web/lib/verify-session.ts
Normal file
47
apps/web/lib/verify-session.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { getBackendUrl } from "./url-helpers"
|
||||
|
||||
const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
|
||||
|
||||
// `bun run dev:local` serves localhost while auth lives on api.supermemory.ai, so its cookie never arrives.
|
||||
function isLocalDevRequest(request: Request): boolean {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return LOCAL_DEV_HOSTS.has(new URL(request.url).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// middleware.ts only checks the cookie is present; metered/proxy routes must verify it server-side.
|
||||
export async function hasVerifiedSession(request: Request): Promise<boolean> {
|
||||
if (isLocalDevRequest(request)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const cookie = request.headers.get("cookie")
|
||||
if (!cookie) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBackendUrl()}/api/auth/get-session`, {
|
||||
headers: { cookie },
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!response.ok) {
|
||||
return false
|
||||
}
|
||||
const session: unknown = await response.json()
|
||||
return Boolean(
|
||||
session &&
|
||||
typeof session === "object" &&
|
||||
"user" in session &&
|
||||
session.user,
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -41,13 +41,14 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// MCP setup page is public — no auth required
|
||||
if (url.searchParams.get("view") === "mcp") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Integrations index is public in guest mode; actions still require login.
|
||||
if (url.pathname === "/" && url.searchParams.get("view") === "integrations") {
|
||||
// Integrations index and MCP setup are public in guest mode; actions still
|
||||
// require login. The ?view param is only meaningful at "/" (see
|
||||
// lib/view-mode-context, which ignores it elsewhere), so scope it there —
|
||||
// unscoped, ?view=mcp would let any path skip the /api/ gate below.
|
||||
if (
|
||||
url.pathname === "/" &&
|
||||
["integrations", "mcp"].includes(url.searchParams.get("view") ?? "")
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@
|
|||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.64",
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"@ai-sdk/xai": "^3.0.83",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
"@cloudflare/ai-chat": "^0.0.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
|
|||
17
bun.lock
17
bun.lock
|
|
@ -148,7 +148,6 @@
|
|||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.64",
|
||||
"@ai-sdk/react": "^3.0.170",
|
||||
"@ai-sdk/xai": "^3.0.83",
|
||||
"@better-fetch/fetch": "^1.1.18",
|
||||
"@cloudflare/ai-chat": "^0.0.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -336,7 +335,7 @@
|
|||
},
|
||||
"packages/tools": {
|
||||
"name": "@supermemory/tools",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.23",
|
||||
|
|
@ -470,7 +469,7 @@
|
|||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.83", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SuQz68BZGeuZjrSUJAzku97IlhdiNJJBsvG/Tvm3K2tuxkBS7TJq0fH4/AzAM7w2H2jxVUgboP7kRR6IfpRxcg=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.67", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw=="],
|
||||
|
||||
"@aihubmix/ai-sdk-provider": ["@aihubmix/ai-sdk-provider@1.0.3", "", { "dependencies": { "@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/google": "^3.0.0", "@ai-sdk/openai": "^3.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "zod": "3.25.76" } }, "sha512-6YSu/3rLkPlY7fqSHTwq6LMiK+0BLVZsbsi/28w+og2MrpUYPNaBhkj7F80K79NE28+HFOqIJROFgAUFmaoh6w=="],
|
||||
|
||||
|
|
@ -5118,11 +5117,11 @@
|
|||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
|
||||
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
|
||||
"@aihubmix/ai-sdk-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q=="],
|
||||
|
||||
|
|
@ -5578,8 +5577,6 @@
|
|||
|
||||
"@voltagent/core/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@voltagent/core/@ai-sdk/xai": ["@ai-sdk/xai@3.0.67", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KQQIDc91dUA5IGFMnXBuvPBeraYNTdpDC1qUS+JG8vE+/299//5sZFafI1kKYUu3f3p7LaZrKXYgZ1Ni7QIRbw=="],
|
||||
|
||||
"@voltagent/core/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@voltagent/core/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.204.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw=="],
|
||||
|
|
@ -6632,10 +6629,6 @@
|
|||
|
||||
"@voltagent/core/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
|
||||
|
||||
"@voltagent/core/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@voltagent/core/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
|
@ -7288,8 +7281,6 @@
|
|||
|
||||
"@voltagent/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@voltagent/core/@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@voltagent/core/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"@voltagent/core/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ type AddMemoryInput = {
|
|||
memory: string
|
||||
}
|
||||
|
||||
// The schema constrains well-behaved models; a prompt-injected one can still send anything.
|
||||
function clampSearchLimit(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 10
|
||||
return Math.min(50, Math.max(1, Math.floor(parsed)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Supermemory tools for AI SDK
|
||||
*/
|
||||
|
|
@ -30,6 +37,8 @@ export function supermemoryTools(
|
|||
) {
|
||||
const client = new Supermemory({
|
||||
apiKey,
|
||||
timeout: 30_000,
|
||||
maxRetries: 2,
|
||||
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
|
||||
})
|
||||
|
||||
|
|
@ -54,8 +63,10 @@ export function supermemoryTools(
|
|||
default: true,
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Maximum number of results to return",
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
description: "Maximum number of results to return (1-50)",
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
|
|
@ -67,10 +78,11 @@ export function supermemoryTools(
|
|||
limit = 10,
|
||||
}) => {
|
||||
try {
|
||||
const safeLimit = clampSearchLimit(limit)
|
||||
const response = await client.search.execute({
|
||||
q: informationToGet,
|
||||
containerTags,
|
||||
limit,
|
||||
limit: safeLimit,
|
||||
chunkThreshold: 0.6,
|
||||
includeFullDocs,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
|
|
|||
|
|
@ -372,4 +372,10 @@ export function supermemoryTools(
|
|||
}
|
||||
}
|
||||
|
||||
export { withSupermemory } from "./vercel"
|
||||
// `./vercel` is not a published subpath, so this is the only way consumers reach the middleware types.
|
||||
export {
|
||||
withSupermemory,
|
||||
type WithSupermemoryOptions,
|
||||
type PromptTemplate,
|
||||
type MemoryPromptData,
|
||||
} from "./vercel"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type OpenAI from "openai"
|
||||
import { validateApiKey } from "../shared"
|
||||
import {
|
||||
createOpenAIMiddleware,
|
||||
type OpenAIMiddlewareOptions,
|
||||
|
|
@ -21,6 +22,7 @@ import {
|
|||
* @param options.verbose - Optional flag to enable detailed logging of memory search and injection process (default: false)
|
||||
* @param options.mode - Optional mode for memory search: "profile" (default), "query", or "full"
|
||||
* @param options.addMemory - Optional mode for memory addition: "always" (default), "never"
|
||||
* @param options.apiKey - Optional Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
|
||||
*
|
||||
* @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs
|
||||
*
|
||||
|
|
@ -56,16 +58,14 @@ import {
|
|||
* })
|
||||
* ```
|
||||
*
|
||||
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
|
||||
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
|
||||
* @throws {Error} When supermemory API request fails
|
||||
*/
|
||||
export function withSupermemory(
|
||||
openaiClient: OpenAI,
|
||||
options: OpenAIMiddlewareOptions,
|
||||
) {
|
||||
if (!process.env.SUPERMEMORY_API_KEY) {
|
||||
throw new Error("SUPERMEMORY_API_KEY is not set")
|
||||
}
|
||||
validateApiKey(options.apiKey)
|
||||
|
||||
if (!options.containerTag) {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type OpenAI from "openai"
|
||||
import Supermemory from "supermemory"
|
||||
import { addConversation } from "../conversations-client"
|
||||
import { validateApiKey } from "../shared"
|
||||
import { deduplicateMemoriesForMode } from "../tools-shared"
|
||||
import { createLogger, type Logger } from "../vercel/logger"
|
||||
import { convertProfileToMarkdown } from "../vercel/util"
|
||||
|
|
@ -20,6 +21,7 @@ export interface OpenAIMiddlewareOptions {
|
|||
mode?: "profile" | "query" | "full"
|
||||
addMemory?: "always" | "never"
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
interface SupermemoryProfileSearch {
|
||||
|
|
@ -75,22 +77,25 @@ const getLastUserMessage = (
|
|||
*
|
||||
* @param containerTag - The container tag/identifier for memory search (e.g., user ID, project ID)
|
||||
* @param queryText - Optional query text to search for specific memories. If empty, returns all profile memories
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @param apiKey - The Supermemory API key used to authenticate the request
|
||||
* @returns Promise that resolves to the SuperMemory profile search response
|
||||
* @throws {Error} When the API request fails or returns an error status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Search with query
|
||||
* const results = await supermemoryProfileSearch("user-123", "favorite programming language")
|
||||
* const results = await supermemoryProfileSearch("user-123", "favorite programming language", baseUrl, apiKey)
|
||||
*
|
||||
* // Get all profile memories
|
||||
* const profile = await supermemoryProfileSearch("user-123", "")
|
||||
* const profile = await supermemoryProfileSearch("user-123", "", baseUrl, apiKey)
|
||||
* ```
|
||||
*/
|
||||
const supermemoryProfileSearch = async (
|
||||
containerTag: string,
|
||||
queryText: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
): Promise<SupermemoryProfileSearch> => {
|
||||
const payload = queryText
|
||||
? JSON.stringify({
|
||||
|
|
@ -106,7 +111,7 @@ const supermemoryProfileSearch = async (
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.SUPERMEMORY_API_KEY}`,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: payload,
|
||||
})
|
||||
|
|
@ -138,6 +143,8 @@ const supermemoryProfileSearch = async (
|
|||
* @param containerTag - The container tag/identifier for memory search
|
||||
* @param logger - Logger instance for debugging and info output
|
||||
* @param mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both)
|
||||
* @param baseUrl - The Supermemory API base URL
|
||||
* @param apiKey - The Supermemory API key used to authenticate the request
|
||||
* @returns Promise that resolves to enhanced messages with memory-injected system prompt
|
||||
*
|
||||
* @example
|
||||
|
|
@ -150,7 +157,9 @@ const supermemoryProfileSearch = async (
|
|||
* messages,
|
||||
* "user-123",
|
||||
* logger,
|
||||
* "full"
|
||||
* "full",
|
||||
* baseUrl,
|
||||
* apiKey
|
||||
* )
|
||||
* // Returns messages with system prompt containing relevant memories
|
||||
* ```
|
||||
|
|
@ -161,6 +170,7 @@ const addSystemPrompt = async (
|
|||
logger: Logger,
|
||||
mode: "profile" | "query" | "full",
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
) => {
|
||||
const systemPromptExists = messages.some((msg) => msg.role === "system")
|
||||
|
||||
|
|
@ -170,6 +180,7 @@ const addSystemPrompt = async (
|
|||
containerTag,
|
||||
queryText,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
)
|
||||
|
||||
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
|
||||
|
|
@ -400,8 +411,9 @@ const addMemoryTool = async (
|
|||
* @param options.verbose - Enable detailed logging of memory operations (default: false)
|
||||
* @param options.mode - Memory search mode: "profile" (all memories), "query" (search-based), or "full" (both) (default: "profile")
|
||||
* @param options.addMemory - Automatic memory storage mode: "always" or "never" (default: "always")
|
||||
* @param options.apiKey - Supermemory API key to use instead of the SUPERMEMORY_API_KEY environment variable
|
||||
* @returns Object with `wrapClient` and `createClient` methods
|
||||
* @throws {Error} When SUPERMEMORY_API_KEY environment variable is not set
|
||||
* @throws {Error} When neither `options.apiKey` nor `process.env.SUPERMEMORY_API_KEY` are set
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
@ -421,8 +433,9 @@ export function createOpenAIMiddleware(
|
|||
) {
|
||||
const logger = createLogger(options?.verbose ?? false)
|
||||
const baseUrl = normalizeBaseUrl(options?.baseUrl)
|
||||
const apiKey = validateApiKey(options?.apiKey)
|
||||
const client = new Supermemory({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY,
|
||||
apiKey,
|
||||
...(baseUrl !== "https://api.supermemory.ai" ? { baseURL: baseUrl } : {}),
|
||||
})
|
||||
|
||||
|
|
@ -457,6 +470,7 @@ export function createOpenAIMiddleware(
|
|||
containerTag,
|
||||
queryText,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
)
|
||||
|
||||
const memoryCountStatic = memoriesResponse.profile.static?.length || 0
|
||||
|
|
@ -615,7 +629,7 @@ export function createOpenAIMiddleware(
|
|||
memoryCustomId,
|
||||
logger,
|
||||
messages,
|
||||
process.env.SUPERMEMORY_API_KEY,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
),
|
||||
)
|
||||
|
|
@ -623,7 +637,7 @@ export function createOpenAIMiddleware(
|
|||
}
|
||||
|
||||
operations.push(
|
||||
addSystemPrompt(messages, containerTag, logger, mode, baseUrl),
|
||||
addSystemPrompt(messages, containerTag, logger, mode, baseUrl, apiKey),
|
||||
)
|
||||
|
||||
const results = await Promise.all(operations)
|
||||
|
|
|
|||
|
|
@ -552,6 +552,14 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool
|
|||
]
|
||||
}
|
||||
|
||||
function parseToolArguments(argumentsJson: string) {
|
||||
try {
|
||||
return { success: true as const, value: JSON.parse(argumentsJson) }
|
||||
} catch {
|
||||
return { success: false as const }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool call based on the function name and arguments
|
||||
*/
|
||||
|
|
@ -565,7 +573,14 @@ export function createToolCallExecutor(
|
|||
toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall,
|
||||
): Promise<string> {
|
||||
const functionName = toolCall.function.name
|
||||
const args = JSON.parse(toolCall.function.arguments)
|
||||
const parsed = parseToolArguments(toolCall.function.arguments)
|
||||
if (!parsed.success) {
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
error: `Invalid JSON arguments for ${functionName}`,
|
||||
})
|
||||
}
|
||||
const args = parsed.value
|
||||
|
||||
switch (functionName) {
|
||||
case "searchMemories":
|
||||
|
|
|
|||
|
|
@ -129,11 +129,7 @@ export function createSupermemoryHooks(
|
|||
return
|
||||
}
|
||||
|
||||
saveConversation(messages, ctx).catch((error) => {
|
||||
ctx.logger.error("Background conversation save failed", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
})
|
||||
await saveConversation(messages, ctx)
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error in onEnd", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ const convertToConversationMessages = (
|
|||
}
|
||||
|
||||
/**
|
||||
* Saves conversation to Supermemory (fire-and-forget).
|
||||
* Saves conversation to Supermemory.
|
||||
*/
|
||||
export const saveConversation = async (
|
||||
messages: VoltAgentMessage[],
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export const AnonymousAuth = ({
|
||||
dashboardPath = "/dashboard",
|
||||
loginPath = "/login",
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const createAnonymousSession = async () => {
|
||||
const session = await authClient.getSession()
|
||||
|
||||
if (!session?.session) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No session found, creating anonymous session...",
|
||||
)
|
||||
|
||||
try {
|
||||
// Create anonymous session
|
||||
console.debug("[ANONYMOUS_AUTH] Calling signIn.anonymous()...")
|
||||
const res = await authClient.signIn.anonymous()
|
||||
|
||||
if (!res.token) {
|
||||
throw new Error("Failed to get anonymous token")
|
||||
}
|
||||
|
||||
// Get the new session
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Getting new session with anonymous token...",
|
||||
)
|
||||
const newSession = await authClient.getSession()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] New session retrieved:", newSession)
|
||||
|
||||
if (!newSession?.session || !newSession?.user) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Failed to create anonymous session - missing session or user",
|
||||
)
|
||||
throw new Error("Failed to create anonymous session")
|
||||
}
|
||||
|
||||
// Get the user's organization
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching organizations for anonymous user...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations retrieved:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
const org = orgs?.[0]
|
||||
if (!org) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] No organization found for anonymous user",
|
||||
)
|
||||
throw new Error("Failed to get organization for anonymous user")
|
||||
}
|
||||
|
||||
// Redirect to the organization dashboard
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting anonymous user to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ANONYMOUS_AUTH] Anonymous session creation error:",
|
||||
error,
|
||||
)
|
||||
console.error("[ANONYMOUS_AUTH] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
router.push(loginPath)
|
||||
}
|
||||
} else if (session.session) {
|
||||
// Session exists, handle organization routing
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Session exists, checking organization...",
|
||||
)
|
||||
|
||||
if (!session.session.activeOrganizationId) {
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] No active organization ID, fetching organizations...",
|
||||
)
|
||||
const orgs = await authClient.organization.list()
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Organizations for existing user:", {
|
||||
count: orgs?.length || 0,
|
||||
orgs: orgs?.map((o) => ({
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
})),
|
||||
})
|
||||
|
||||
if (orgs?.[0]) {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Setting active organization to ${orgs[0].id}`,
|
||||
)
|
||||
await authClient.organization.setActive({
|
||||
organizationId: orgs[0].id,
|
||||
})
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${orgs[0].slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Active organization ID: ${session.session.activeOrganizationId}`,
|
||||
)
|
||||
console.debug(
|
||||
"[ANONYMOUS_AUTH] Fetching full organization details...",
|
||||
)
|
||||
const org = await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: session.session.activeOrganizationId,
|
||||
},
|
||||
})
|
||||
|
||||
console.debug("[ANONYMOUS_AUTH] Full organization retrieved:", {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
})
|
||||
|
||||
console.debug(
|
||||
`[ANONYMOUS_AUTH] Redirecting to /${org.slug}${dashboardPath}`,
|
||||
)
|
||||
router.push(dashboardPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAnonymousSession()
|
||||
}, [router.push])
|
||||
|
||||
// Return null as this component only handles the redirect logic
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import {
|
||||
BulkDeleteMemoriesSchema,
|
||||
DocumentsWithMemoriesQuerySchema,
|
||||
ListMemoriesQuerySchema,
|
||||
SearchRequestSchema,
|
||||
|
|
@ -151,4 +152,26 @@ describe("pagination query schemas", () => {
|
|||
expect(parsed.page).toBe(2)
|
||||
expect(parsed.limit).toBe(50)
|
||||
})
|
||||
|
||||
it("DocumentsWithMemoriesQuerySchema caps limit at 1000", () => {
|
||||
expect(
|
||||
DocumentsWithMemoriesQuerySchema.safeParse({ limit: 1001 }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
DocumentsWithMemoriesQuerySchema.safeParse({ limit: 200 }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("BulkDeleteMemoriesSchema caps containerTags at 100 entries of bounded length", () => {
|
||||
const tooMany = {
|
||||
containerTags: Array.from({ length: 101 }, (_, i) => `tag_${i}`),
|
||||
}
|
||||
expect(BulkDeleteMemoriesSchema.safeParse(tooMany).success).toBe(false)
|
||||
|
||||
const tagTooLong = { containerTags: ["x".repeat(257)] }
|
||||
expect(BulkDeleteMemoriesSchema.safeParse(tagTooLong).success).toBe(false)
|
||||
|
||||
const ok = { containerTags: ["tag_a", "tag_b"] }
|
||||
expect(BulkDeleteMemoriesSchema.safeParse(ok).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1102,8 +1102,8 @@ export const DocumentsWithMemoriesQuerySchema = z
|
|||
description: "Page number to fetch",
|
||||
example: 1,
|
||||
}),
|
||||
limit: z.number().int().min(1).default(10).openapi({
|
||||
description: "Number of items per page",
|
||||
limit: z.number().int().min(1).max(1000).default(10).openapi({
|
||||
description: "Number of items per page (max 1000)",
|
||||
example: 10,
|
||||
}),
|
||||
sort: z.enum(["createdAt", "updatedAt"]).default("createdAt").openapi({
|
||||
|
|
@ -1409,12 +1409,13 @@ export const BulkDeleteMemoriesSchema = z
|
|||
example: ["acxV5LHMEsG2hMSNb4umbn", "bxcV5LHMEsG2hMSNb4umbn"],
|
||||
}),
|
||||
containerTags: z
|
||||
.array(z.string())
|
||||
.array(z.string().max(256))
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
"Array of container tags - all memories in these containers will be deleted",
|
||||
"Array of container tags - all memories in these containers will be deleted (max 100 at once)",
|
||||
example: ["user_123", "project_123"],
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue