diff --git a/apps/api/src/billing/autumn.ts b/apps/api/src/billing/autumn.ts index 58e563010..c09411ad6 100644 --- a/apps/api/src/billing/autumn.ts +++ b/apps/api/src/billing/autumn.ts @@ -48,69 +48,64 @@ async function stripPrivilegedBody(request: Request): Promise { } const text = await request.text(); - if (!text) { - return new Request(request.url, request); - } - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return new Request(request.url, { - method: request.method, - headers: request.headers, - body: text, - }); + let body: string | null = text || null; + if (text) { + try { + body = JSON.stringify(sanitize(JSON.parse(text))); + } catch { + body = text; + } } return new Request(request.url, { method: request.method, headers: request.headers, - body: JSON.stringify(sanitize(parsed)), + body, }); } +const autumn = autumnHandler({ identify: identifyAutumnCustomer }); + export async function handleAutumnRequest(request: Request) { const sanitized = await stripPrivilegedBody(request); - return autumnHandler({ - identify: identifyAutumnCustomer, - })(withAutumnApiPath(sanitized)); + return autumn(withAutumnApiPath(sanitized)); } -async function identifyAutumnCustomer(request: Request) { +async function loadSession(request: Request) { try { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session?.user) { - return null; - } + return await auth.api.getSession({ headers: request.headers }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + useLogger().error(err, { + autumn: "identify", + autumn_stage: "getSession", + }); + throw err; + } +} + +async function identifyAutumnCustomer(request: Request) { + const session = await loadSession(request); + if (!session?.user) { + return null; + } - const activeOrgId = ( - session.session as { activeOrganizationId?: string | null } - )?.activeOrganizationId; + const activeOrgId = session.session.activeOrganizationId ?? null; - if (activeOrgId) { - const role = await getMemberRole(session.user.id, activeOrgId); - if (role !== "owner" && role !== "admin") { - return null; - } + if (activeOrgId) { + const role = await getMemberRole(session.user.id, activeOrgId); + if (role !== "owner" && role !== "admin") { + return null; } + } - const customerId = await getBillingCustomerId(session.user.id, activeOrgId); + const customerId = await getBillingCustomerId(session.user.id, activeOrgId); - return { - customerId, - customerData: { - name: session.user.name, - email: session.user.email, - }, - }; - } catch (error) { - useLogger().error( - error instanceof Error ? error : new Error(String(error)), - { - autumn: "identify", - } - ); - return null; - } + return { + customerId, + customerData: { + name: session.user.name, + email: session.user.email, + }, + }; } diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 5e6082305..75df5fb03 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -1,7 +1,5 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasGlobalAccess, hasKeyScope, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; @@ -60,7 +58,7 @@ import { Elysia, t } from "elysia"; import { log, parseError } from "evlog"; import { useLogger } from "evlog/elysia"; import { - checkWebsiteReadPermissionCached, + type AgentContextSnapshotResult, getAgentContextSnapshot, getMemoryContextCached, shouldLoadMemoryContext, @@ -69,7 +67,7 @@ import { getAILogger } from "../lib/ai-logger"; import { trackAgentEvent } from "../lib/databuddy"; import { getResolvedAuth } from "../lib/auth-wide-event"; import { captureError, mergeWideEvent } from "../lib/tracing"; -import { validateWebsite } from "../lib/website-utils"; +import { getAccessibleWebsites } from "../lib/accessible-websites"; function jsonError(status: number, code: string, message: string): Response { return new Response( @@ -228,8 +226,12 @@ const UIMessageSchema = t.Object({ ), }); +const MAX_MENTIONS = 20; + const AgentRequestSchema = t.Object({ - websiteId: t.String(), + organizationId: t.Optional(t.String()), + websiteId: t.Optional(t.String()), + mentions: t.Optional(t.Array(t.String(), { maxItems: MAX_MENTIONS })), messages: t.Array(UIMessageSchema, { maxItems: MAX_MESSAGES }), id: t.Optional(t.String()), timezone: t.Optional(t.String()), @@ -475,25 +477,31 @@ function createPlainTextStreamResponse( export const agent = new Elysia({ prefix: "/v1/agent" }) .derive(async ({ request }) => { const preResolved = getResolvedAuth(request.headers); - let user = preResolved?.session?.user ?? null; + let session = preResolved?.session ?? null; let apiKey = preResolved?.apiKeyResult?.key ?? null; if (!preResolved) { const hasApiKey = isApiKeyPresent(request.headers); - const [resolvedApiKey, session] = await Promise.all([ + const [resolvedApiKey, freshSession] = await Promise.all([ hasApiKey ? getApiKeyFromHeader(request.headers) : null, auth.api.getSession({ headers: request.headers }), ]); - user = session?.user ?? null; + session = freshSession; apiKey = resolvedApiKey; } + const user = session?.user ?? null; + const activeOrganizationId = + (session?.session as { activeOrganizationId?: string | null } | undefined) + ?.activeOrganizationId ?? null; + const validApiKey = apiKey && hasKeyScope(apiKey, "read:data") ? apiKey : null; return { user, apiKey: validApiKey, + activeOrganizationId, isAuthenticated: Boolean(user ?? validApiKey), }; }) @@ -591,14 +599,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ) .post( "/chat", - function agentChat({ body, user, apiKey, request }) { + function agentChat({ body, user, apiKey, activeOrganizationId, request }) { return (async () => { const chatId = body.id ?? generateId(); const t0 = performance.now(); let organizationId: string | null = null; mergeWideEvent({ - agent_website_id: body.websiteId, agent_user_id: user?.id ?? "unknown", agent_chat_id: chatId, }); @@ -609,8 +616,27 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) } const userId = user?.id ?? `apikey:${apiKey?.id}`; + organizationId = + body.organizationId ?? + activeOrganizationId ?? + apiKey?.organizationId ?? + null; + + if (!organizationId) { + return jsonError( + 400, + "WORKSPACE_REQUIRED", + "No active workspace. Select an organization and try again." + ); + } + + mergeWideEvent({ + organization_id: organizationId, + ...(body.websiteId ? { agent_website_id: body.websiteId } : {}), + }); + const rl = await ratelimit( - `agent:chat:${userId}:${body.websiteId}`, + `agent:chat:${userId}:${organizationId}`, 30, 60 ); @@ -622,50 +648,15 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ); } - const websiteValidation = await timeAgentPhase( - "validate_website", - validateWebsite(body.websiteId) - ); - - if (!(websiteValidation.success && websiteValidation.website)) { - return jsonError( - 404, - "WEBSITE_NOT_FOUND", - websiteValidation.error ?? "Website not found" - ); - } - - const { website } = websiteValidation; - organizationId = website.organizationId ?? null; - - const resolvePermission = (): Promise => { - if (apiKey) { - if (hasGlobalAccess(apiKey)) { - return Promise.resolve( - apiKey.organizationId != null && - apiKey.organizationId === website.organizationId - ); - } - return Promise.resolve( - getAccessibleWebsiteIds(apiKey).includes(body.websiteId) - ); - } - if (!(user && website.organizationId)) { - return Promise.resolve(false); - } - return checkWebsiteReadPermissionCached( - user.id, - website.organizationId, - request.headers - ); - }; - const permissionCheck = timeAgentPhase( - "permission_check", - resolvePermission() - ); - - const [hasPermission, billingCustomerId] = await Promise.all([ - permissionCheck, + const [accessibleWebsites, billingCustomerId] = await Promise.all([ + timeAgentPhase( + "accessible_websites", + getAccessibleWebsites({ + user: user ? { id: user.id } : null, + apiKey, + organizationId, + }) + ), timeAgentPhase( "resolve_billing", resolveAgentBillingCustomerId({ @@ -676,30 +667,53 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) ), ]); - if (!hasPermission) { + if (accessibleWebsites.length === 0) { return jsonError( 403, "ACCESS_DENIED", - "Access denied to this website" + "No accessible websites in this workspace" ); } + let defaultWebsiteId: string | null = null; + let defaultDomain: string | undefined; + if (body.websiteId) { + const match = accessibleWebsites.find( + (w) => w.id === body.websiteId + ); + if (!match) { + return jsonError( + 403, + "ACCESS_DENIED", + "Access denied to this website" + ); + } + defaultWebsiteId = match.id; + defaultDomain = match.domain ?? undefined; + } + + const mentionedWebsites = (body.mentions ?? []) + .map((id) => accessibleWebsites.find((w) => w.id === id)) + .filter((w): w is (typeof accessibleWebsites)[number] => + Boolean(w) + ); + if (body.id) { const existingChat = await db.query.agentChats.findFirst({ where: { id: chatId }, - columns: { userId: true, websiteId: true }, + columns: { userId: true, organizationId: true }, }); if ( existingChat && (existingChat.userId !== userId || - existingChat.websiteId !== body.websiteId) + (existingChat.organizationId != null && + existingChat.organizationId !== organizationId)) ) { return jsonError(403, "ACCESS_DENIED", "Access denied to chat"); } } const timezone = body.timezone ?? "UTC"; - const domain = website.domain ?? "unknown"; const lastMessage = getLastMessagePreview(body.messages); const agentTier: AgentTier = body.tier ?? "balanced"; @@ -714,7 +728,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) action: "chat_started", source: "dashboard", agent_type: AGENT_TYPE, - website_id: body.websiteId, + website_id: defaultWebsiteId, organization_id: organizationId, user_id: userId, }); @@ -722,7 +736,8 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) useLogger().info("Creating agent", { agent: { type: AGENT_TYPE, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, + accessibleWebsiteCount: accessibleWebsites.length, messageCount: body.messages.length, lastMessage, }, @@ -735,7 +750,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(err, { agent_credit_check_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); return true; }) @@ -759,28 +776,41 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) "memory_enrich", Promise.all([ creditsCheck, - loadMemoryContext + loadMemoryContext && defaultWebsiteId ? optionalAgentContext( "memory", - getMemoryContextCached(lastMessage, userId, body.websiteId), + getMemoryContextCached( + lastMessage, + userId, + defaultWebsiteId + ), EMPTY_MEMORY_CONTEXT, AGENT_MEMORY_CONTEXT_TIMEOUT_MS, { agent_chat_id: chatId, - agent_website_id: body.websiteId, + agent_website_id: defaultWebsiteId, } ) : Promise.resolve(EMPTY_MEMORY_CONTEXT), - optionalAgentContext( - "enrichment", - getAgentContextSnapshot(userId, body.websiteId, organizationId), - { context: "", source: "error" }, - AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS, - { - agent_chat_id: chatId, - agent_website_id: body.websiteId, - } - ), + defaultWebsiteId + ? optionalAgentContext( + "enrichment", + getAgentContextSnapshot( + userId, + defaultWebsiteId, + organizationId + ), + { context: "", source: "error" }, + AGENT_ENRICHMENT_CONTEXT_TIMEOUT_MS, + { + agent_chat_id: chatId, + agent_website_id: defaultWebsiteId, + } + ) + : Promise.resolve({ + context: "", + source: "miss", + }), ]) ); mergeWideEvent({ @@ -805,8 +835,10 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) { userId, organizationId: organizationId ?? undefined, - websiteId: body.websiteId, - websiteDomain: domain, + websiteId: defaultWebsiteId ?? undefined, + websiteDomain: defaultDomain, + defaultWebsiteId, + accessibleWebsites, timezone, chatId, requestHeaders: request.headers, @@ -817,9 +849,20 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) modelOverride ); + const mentionContext = + mentionedWebsites.length > 0 + ? `\nThe user referenced these websites in their message. Prioritize them when choosing which website(s) to query:\n${mentionedWebsites + .map( + (w) => + `- ${w.name ?? w.domain ?? w.id} (id: ${w.id}${w.domain ? `, domain: ${w.domain}` : ""})` + ) + .join("\n")}\n` + : ""; + const extras = [ memoryCtx ? formatMemoryForPrompt(memoryCtx) : "", enrichment.context, + mentionContext, ] .filter(Boolean) .join("\n\n"); @@ -863,14 +906,18 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) const dashboardTelemetryMetadata: Record = { source: "dashboard", userId, - websiteId: body.websiteId, - websiteDomain: domain, chatId, agentType: AGENT_TYPE, timezone, "tcc.sessionId": chatId, "tcc.conversational": "true", }; + if (defaultWebsiteId) { + dashboardTelemetryMetadata.websiteId = defaultWebsiteId; + } + if (defaultDomain) { + dashboardTelemetryMetadata.websiteDomain = defaultDomain; + } if (organizationId) { dashboardTelemetryMetadata.organizationId = organizationId; } @@ -881,7 +928,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: dashboardTelemetryMetadata, }); - if (isMemoryEnabled() && lastMessage) { + if (isMemoryEnabled() && lastMessage && defaultWebsiteId) { storeConversation( [{ role: "user", content: lastMessage }], userId, @@ -890,9 +937,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) metadata: { source: "dashboard", }, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, conversationId: chatId, - domain, + domain: defaultDomain ?? "unknown", } ); } @@ -941,7 +988,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) modelId: modelNames[modelKey], source: "dashboard", agentType: AGENT_TYPE, - websiteId: body.websiteId, + websiteId: defaultWebsiteId ?? undefined, organizationId, userId: persistedUserId ?? null, chatId, @@ -952,14 +999,17 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(usageError, { agent_usage_telemetry_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); }); + const streamScope = userId; const streamId = generateId(); - const streamKey = streamBufferKey(body.websiteId, chatId, streamId); + const streamKey = streamBufferKey(streamScope, chatId, streamId); await timeAgentPhase("stream_setup", () => - setActiveStream(body.websiteId, chatId, streamId) + setActiveStream(streamScope, chatId, streamId) ); if (persistedUserId) { @@ -969,7 +1019,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) .insert(agentChats) .values({ id: chatId, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, userId: persistedUserId, organizationId: persistedOrgId, title: fallbackTitle, @@ -988,7 +1038,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(persistError, { agent_user_message_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); } } @@ -998,7 +1050,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) originalMessages: validation.data, onFinish: async ({ messages }) => { try { - await clearActiveStream(body.websiteId, chatId, streamId); + await clearActiveStream(streamScope, chatId, streamId); } catch {} if (!persistedUserId) { return; @@ -1008,7 +1060,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) .insert(agentChats) .values({ id: chatId, - websiteId: body.websiteId, + websiteId: defaultWebsiteId, userId: persistedUserId, organizationId: persistedOrgId, title: fallbackTitle, @@ -1036,7 +1088,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(persistError, { agent_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); } }, @@ -1069,7 +1123,9 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) captureError(storageError, { agent_stream_persist_error: true, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(defaultWebsiteId + ? { agent_website_id: defaultWebsiteId } + : {}), }); }); return new Response(forClient, { @@ -1088,7 +1144,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) agentType: AGENT_TYPE, phase: "dashboard_chat_stream", userId: user?.id ?? null, - websiteId: body.websiteId, + websiteId: body.websiteId ?? null, }, ...(parsed.fix !== "" && parsed.fix != null ? { fix: parsed.fix } @@ -1104,7 +1160,7 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) error_message: err.message, error_name: err.name, service: "api", - websiteId: body.websiteId, + websiteId: body.websiteId ?? null, ...(parsed.fix !== "" && parsed.fix != null ? { fix: parsed.fix } : {}), @@ -1121,13 +1177,13 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) error_type: getErrorName(error), organization_id: organizationId, user_id: user?.id ?? null, - website_id: body.websiteId, + website_id: body.websiteId ?? null, }); captureError(error, { agent_error: true, agent_type: AGENT_TYPE, agent_chat_id: chatId, - agent_website_id: body.websiteId, + ...(body.websiteId ? { agent_website_id: body.websiteId } : {}), agent_user_id: user?.id ?? "unknown", error_type: getErrorName(error), }); @@ -1143,16 +1199,16 @@ export const agent = new Elysia({ prefix: "/v1/agent" }) } const chat = await db.query.agentChats.findFirst({ where: { id: params.chatId, userId: user.id }, - columns: { id: true, websiteId: true }, + columns: { id: true }, }); if (!chat) { return new Response(null, { status: 204 }); } - const streamId = await getActiveStream(chat.websiteId, chat.id); + const streamId = await getActiveStream(user.id, chat.id); if (!streamId) { return new Response(null, { status: 204 }); } - const key = streamBufferKey(chat.websiteId, chat.id, streamId); + const key = streamBufferKey(user.id, chat.id, streamId); const abortController = new AbortController(); request.signal?.addEventListener("abort", () => { diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 96fff5c7d..c71865177 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -21,6 +21,8 @@ import { Elysia } from "elysia"; import { useLogger } from "evlog/elysia"; import { Resend } from "resend"; import { Webhook } from "svix"; +// biome-ignore lint/performance/noNamespaceImport: vitest+bun fails to bind zod's named `z` export; namespace import is the reliable form +import * as z from "zod"; import { mergeWideEvent } from "../../lib/tracing"; const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; @@ -32,43 +34,52 @@ const resend = new Resend(process.env.RESEND_API_KEY); const svix = SVIX_SECRET ? new Webhook(SVIX_SECRET) : null; const slack = SLACK_URL ? new SlackProvider({ webhookUrl: SLACK_URL }) : null; -interface LimitReachedData { - customer_id: string; - feature_id: string; - limit_type: "included" | "max_purchase" | "spend_limit"; -} - -interface UsageAlertData { - customer_id: string; - feature_id: string; - usage_alert: { - name?: string; - threshold: number; - threshold_type: string; - }; -} - -type ProductScenario = - | "new" - | "upgrade" - | "downgrade" - | "renew" - | "cancel" - | "expired" - | "past_due" - | "scheduled"; - -interface ProductsUpdatedData { - customer: { - id: string | null; - name: string | null; - email: string | null; - env: string; - products: Array<{ id: string; name: string; status: string }>; - }; - scenario: ProductScenario; - updated_product: { id: string; name: string | null }; -} +const limitReachedSchema = z.object({ + customer_id: z.string(), + feature_id: z.string(), + limit_type: z.enum(["included", "max_purchase", "spend_limit"]), +}); + +const usageAlertSchema = z.object({ + customer_id: z.string(), + feature_id: z.string(), + usage_alert: z.object({ + name: z.string().optional(), + threshold: z.number(), + threshold_type: z.string(), + }), +}); + +const productScenarioSchema = z.enum([ + "new", + "upgrade", + "downgrade", + "renew", + "cancel", + "expired", + "past_due", + "scheduled", +]); + +type ProductScenario = z.infer; + +const productsUpdatedSchema = z.object({ + customer: z.object({ + id: z.string().nullable(), + name: z.string().nullable(), + email: z.string().nullable(), + env: z.string(), + products: z.array( + z.object({ id: z.string(), name: z.string(), status: z.string() }) + ), + }), + scenario: productScenarioSchema, + updated_product: z.object({ id: z.string(), name: z.string().nullable() }), +}); + +type LimitReachedData = z.infer; +type UsageAlertData = z.infer; +type ProductsUpdatedData = z.infer; interface RawAutumnEvent { data: unknown; @@ -386,11 +397,11 @@ function dispatch( ): Promise | WebhookResult { switch (event.type) { case "balances.limit_reached": - return handleLimitReached(event.data as LimitReachedData); + return handleLimitReached(limitReachedSchema.parse(event.data)); case "balances.usage_alert_triggered": - return handleUsageAlert(event.data as UsageAlertData); + return handleUsageAlert(usageAlertSchema.parse(event.data)); case "customer.products.updated": - return handleProductsUpdated(event.data as ProductsUpdatedData); + return handleProductsUpdated(productsUpdatedSchema.parse(event.data)); default: useLogger().warn("Unknown webhook type", { autumn: { type: event.type }, @@ -446,7 +457,18 @@ export const autumnWebhook = new Elysia().post( }); log.info("Autumn webhook", { autumn: { type: event.type } }); - return dispatch(event); + try { + return await dispatch(event); + } catch (error) { + if (error instanceof z.ZodError) { + log.error(new Error("Invalid Autumn webhook payload"), { + autumn: { type: event.type, issues: error.issues }, + }); + set.status = 400; + return { success: false, message: "Invalid event payload" }; + } + throw error; + } }, { parse: "none" } ); diff --git a/apps/basket/src/lib/billing.ts b/apps/basket/src/lib/billing.ts index 3aa51bdf8..9941f7ad1 100644 --- a/apps/basket/src/lib/billing.ts +++ b/apps/basket/src/lib/billing.ts @@ -39,6 +39,16 @@ export function checkAutumnUsage( }); if (!response.allowed) { + log.warn("Event quota exceeded", { + customerId, + featureId, + properties, + billing: { + usage: b?.usage, + granted: b?.granted, + unlimited: b?.unlimited, + }, + }); throw basketErrors.billingLimitExceeded(); } diff --git a/apps/basket/src/lib/event-service.ts b/apps/basket/src/lib/event-service.ts index 05c1246f3..8e2681bf6 100644 --- a/apps/basket/src/lib/event-service.ts +++ b/apps/basket/src/lib/event-service.ts @@ -117,9 +117,6 @@ export function buildTrackEvent( }; } -/** - * Insert a track event (pageview/analytics event) via Kafka - */ export function insertTrackEvent( trackData: any, clientId: string, @@ -180,9 +177,6 @@ export function insertTrackEvent( }); } -/** - * Insert an outgoing link click event into the database - */ export function insertOutgoingLink( linkData: any, clientId: string, @@ -246,9 +240,6 @@ export function insertTrackEventsBatch( }); } -/** - * Insert lean error spans (v2.x format) - */ export function insertErrorSpans( errors: ErrorSpan[], clientId: string @@ -294,10 +285,6 @@ export function insertErrorSpans( }); } -/** - * Insert individual vital metrics (v2.x format) as spans - * Each metric is stored as a separate row - no aggregation - */ export function insertIndividualVitals( vitals: IndividualVital[], clientId: string @@ -341,13 +328,6 @@ export function insertOutgoingLinksBatch( }); } -/** - * Insert organization-scoped custom events - * owner_id: The org or user ID that owns this data (from API key) - * website_id: Optional website scope - * namespace: Optional logical grouping (e.g., 'billing', 'auth', 'api') - * source: Optional origin identifier (e.g., 'backend', 'webhook', 'cli') - */ export function insertCustomEvents( events: Array<{ owner_id: string; diff --git a/apps/basket/src/lib/evlog-basket.ts b/apps/basket/src/lib/evlog-basket.ts index 279265eb4..dfa581f91 100644 --- a/apps/basket/src/lib/evlog-basket.ts +++ b/apps/basket/src/lib/evlog-basket.ts @@ -18,9 +18,6 @@ const pipeline = createDrainPipeline({ const axiomDrain = createAxiomDrain(); -/** - * Batched Axiom drain; call {@link flushBatchedAxiomDrain} on shutdown. - */ const batchedAxiomDrain = pipeline(axiomDrain); const devFsLogsDir = join( @@ -40,9 +37,6 @@ const devFsDrain = useLocalEvlogFiles const DURATION_MS_REGEX = /^([\d.]+)(ms|s)$/; -/** - * Before Axiom: fix `error` string vs object collision; downgrade 4xx to warn. - */ function normalizeWideEventForAxiom(event: Record): void { if (typeof event.error === "string") { event.error_message = event.error; @@ -78,10 +72,6 @@ function parseDurationMs(duration: unknown): number | undefined { : Math.round(Number.parseFloat(match[1])); } -/** - * In development, writes NDJSON wide events to `apps/basket/.evlog/logs/` (analyze-logs skill) - * and still sends to Axiom via the batched pipeline. Production: Axiom only. - */ export async function basketLoggerDrain(ctx: DrainContext): Promise { if (ctx.event.method === "OPTIONS") { return; diff --git a/apps/basket/src/lib/request-validation.ts b/apps/basket/src/lib/request-validation.ts index 788cea889..f6fd33bf0 100644 --- a/apps/basket/src/lib/request-validation.ts +++ b/apps/basket/src/lib/request-validation.ts @@ -50,10 +50,6 @@ export function getWebsiteSecuritySettings( }; } -/** - * Validate incoming request for analytics events. - * Throws basket ingest EvlogErrors on failure; returns `{ error: billing.response }` when quota is exceeded. - */ export function validateRequest( body: unknown, query: unknown, @@ -242,12 +238,6 @@ export function validateRequest( }); } -/** - * Check if request is from a bot - * - ALLOW: Process normally (search engines, social media) - * - TRACK_ONLY: Log to ai_traffic_spans but don't count as pageview (AI crawlers) - * - BLOCK: Reject and log to blocked_traffic (scrapers, malicious bots) - */ export function checkForBot( request: Request, body: unknown, diff --git a/apps/basket/src/lib/structured-errors.ts b/apps/basket/src/lib/structured-errors.ts index 75ba25d10..21371b8f7 100644 --- a/apps/basket/src/lib/structured-errors.ts +++ b/apps/basket/src/lib/structured-errors.ts @@ -1,11 +1,6 @@ import { createError, EvlogError, parseError } from "evlog"; import type { z } from "zod"; -/** - * Structured errors for the basket API (evlog EvlogError). - * Prefer throwing these over ad-hoc Response bodies so the global handler can - * emit consistent JSON and wide-event context. - */ export const basketErrors = { trackPayloadTooLarge: () => createError({ @@ -194,9 +189,6 @@ export function isIngestSchemaValidationError( ); } -/** - * Re-throw EvlogErrors; wrap anything else as a 500 and log it. - */ export function rethrowOrWrap( error: unknown, log?: { error: (err: Error) => void } diff --git a/apps/basket/src/lib/tracing.ts b/apps/basket/src/lib/tracing.ts index 4e5065791..bc641daae 100644 --- a/apps/basket/src/lib/tracing.ts +++ b/apps/basket/src/lib/tracing.ts @@ -1,11 +1,6 @@ import { EvlogError, log } from "evlog"; import { useLogger } from "evlog/elysia"; -/** - * Merge fields into the active request wide event, falling back to a - * global structured log line if there is no active request context - * (background workers, startup, async callbacks, etc.). - */ export function mergeWideEvent(fields: Record): void { try { useLogger().set(fields); @@ -14,11 +9,6 @@ export function mergeWideEvent(fields: Record): void { } } -/** - * Run a named operation and attach its duration (ms) to the active wide event - * as `timing.`. Nested calls accumulate — the wide event ends up with one - * `timing.*` field per `record()` call in the request. - */ export async function record( name: string, fn: () => Promise | T @@ -34,10 +24,6 @@ export async function record( } } -/** - * Attach an error to the active request wide event when inside the evlog - * middleware; otherwise emit a global structured log line. - */ export function captureError( error: unknown, attributes?: Record diff --git a/apps/basket/src/routes/basket.ts b/apps/basket/src/routes/basket.ts index 31512b3fb..abf150004 100644 --- a/apps/basket/src/routes/basket.ts +++ b/apps/basket/src/routes/basket.ts @@ -2,6 +2,10 @@ import type { AnalyticsEvent, CustomOutgoingLink, } from "@databuddy/db/clickhouse/schema"; +import type { + AnalyticsEventInput, + OutgoingLinkInput, +} from "@databuddy/validation"; import { analyticsEventSchema, batchedCustomEventSpansSchema, @@ -57,7 +61,7 @@ import { EvlogError } from "evlog"; import { useLogger } from "evlog/elysia"; function processTrackEventData( - trackData: any, + trackData: AnalyticsEventInput, clientId: string, userAgent: string, ip: string, @@ -90,7 +94,7 @@ function processTrackEventData( } async function processOutgoingLinkData( - linkData: any, + linkData: OutgoingLinkInput, clientId: string ): Promise { const timestamp = parseTimestamp(linkData.timestamp); @@ -339,7 +343,8 @@ const app = new Elysia() query, request ); - const eventType = (body as any).type || "track"; + const eventType = + (body as { type?: string } | null | undefined)?.type ?? "track"; log.set({ clientId, eventType }); if (eventType === "track") { @@ -364,7 +369,7 @@ const app = new Elysia() throw createIngestSchemaValidationError(parseResult.error.issues); } - insertTrackEvent(body, clientId, userAgent, ip, request); + insertTrackEvent(parseResult.data, clientId, userAgent, ip, request); return Response.json({ status: "success", type: "track" }); } @@ -390,7 +395,7 @@ const app = new Elysia() throw createIngestSchemaValidationError(parseResult.error.issues); } - insertOutgoingLink(body, clientId, userAgent, ip); + insertOutgoingLink(parseResult.data, clientId, userAgent, ip); return Response.json({ status: "success", type: "outgoing_link" }); } @@ -474,7 +479,7 @@ const app = new Elysia() } const trackEvent = await processTrackEventData( - event, + parseResult.data, clientId, userAgent, ip, @@ -484,7 +489,7 @@ const app = new Elysia() results.push({ status: "success", type: "track", - eventId: event.eventId, + eventId: parseResult.data.eventId, }); } else if (eventType === "outgoing_link") { const botError = await checkForBot( @@ -518,12 +523,15 @@ const app = new Elysia() continue; } - const linkEvent = await processOutgoingLinkData(event, clientId); + const linkEvent = await processOutgoingLinkData( + parseResult.data, + clientId + ); outgoingLinkEvents.push(linkEvent); results.push({ status: "success", type: "outgoing_link", - eventId: event.eventId, + eventId: parseResult.data.eventId, }); } else { results.push({ diff --git a/apps/basket/src/utils/parsing-helpers.ts b/apps/basket/src/utils/parsing-helpers.ts index 4d58c2ae0..ee85af583 100644 --- a/apps/basket/src/utils/parsing-helpers.ts +++ b/apps/basket/src/utils/parsing-helpers.ts @@ -7,9 +7,6 @@ type ParseResult = | { success: true; data: T } | { success: false; error: { issues: z.core.$ZodIssue[] } }; -/** - * Validates event schema in production, skips validation in development - */ export function validateEventSchema( schema: z.ZodSchema, event: unknown, @@ -46,7 +43,6 @@ export function validateEventSchema( }); } -/** Per-item batch result when schema validation fails */ export function batchSchemaItemFailure( issues: z.core.$ZodIssue[], eventType: string, @@ -61,7 +57,6 @@ export function batchSchemaItemFailure( }; } -/** Per-item batch result when request is treated as bot (ignored) */ export function batchBotIgnoredItem(eventType: string) { return { status: "error" as const, @@ -71,23 +66,14 @@ export function batchBotIgnoredItem(eventType: string) { }; } -/** - * Validates timestamp, returns current time if invalid - */ export function parseTimestamp(timestamp: unknown): number { return typeof timestamp === "number" ? timestamp : Date.now(); } -/** - * Parses properties object to JSON string, defaults to empty object - */ export function parseProperties(properties: unknown): string { return properties ? JSON.stringify(properties) : "{}"; } -/** - * Parses and sanitizes event ID, generates UUID if missing - */ export function parseEventId( eventId: unknown, generateFn: () => string diff --git a/apps/dashboard/app/(main)/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx new file mode 100644 index 000000000..56af147e8 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/[chatId]/page.tsx @@ -0,0 +1,11 @@ +import { GlobalAgentPage } from "../_components/global-agent-page"; + +interface Props { + params: Promise<{ chatId: string }>; +} + +export default async function AgentChatPage(props: Props) { + const { chatId } = await props.params; + + return ; +} diff --git a/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx b/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx new file mode 100644 index 000000000..f9c6ca29d --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/agent-gate.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { Skeleton } from "@databuddy/ui"; +import { AgentNoWebsites } from "./agent-no-websites"; + +type AgentGate = + | { status: "loading" | "no-websites" } + | { status: "ready"; organizationId: string }; + +export function useAgentGate(): AgentGate { + const { organizationId, hasWebsites, isLoading } = useGlobalAgent(); + if (isLoading || !organizationId) { + return { status: "loading" }; + } + if (!hasWebsites) { + return { status: "no-websites" }; + } + return { status: "ready", organizationId }; +} + +export function AgentGatePlaceholder({ + status, +}: { + status: "loading" | "no-websites"; +}) { + if (status === "no-websites") { + return ; + } + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx new file mode 100644 index 000000000..de5c57d95 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/agent-no-websites.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; +import { Button } from "@databuddy/ui"; +import { Avatar } from "@databuddy/ui/client"; + +export function AgentNoWebsites() { + return ( +
+ +
+

+ Add a website to use Databunny +

+

+ Databunny analyzes your analytics. Connect your first website to start + asking questions. +

+
+ +
+ ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx new file mode 100644 index 000000000..090116bde --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { clearLastChatId } from "@/components/agent/hooks/use-chat-db"; +import { ChatProvider } from "@/contexts/chat-context"; +import { AgentGatePlaceholder, useAgentGate } from "./agent-gate"; + +export function GlobalAgentPage({ chatId }: { chatId: string }) { + const router = useRouter(); + const gate = useAgentGate(); + + if (gate.status !== "ready") { + return ; + } + + const { organizationId } = gate; + + return ( + + { + if (nextChatId) { + router.push(`/agent/${nextChatId}`); + } else { + clearLastChatId(organizationId); + router.push("/agent"); + } + }} + onNewChat={(newChatId) => router.push(`/agent/${newChatId}`)} + onSelectChat={(nextChatId) => router.push(`/agent/${nextChatId}`)} + organizationId={organizationId} + /> + + ); +} diff --git a/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx b/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx new file mode 100644 index 000000000..39fb84a9d --- /dev/null +++ b/apps/dashboard/app/(main)/agent/_components/global-agent-redirect.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useGlobalAgent } from "@/components/agent/global-agent-provider"; +import { AgentGatePlaceholder, useAgentGate } from "./agent-gate"; + +export function GlobalAgentRedirect() { + const router = useRouter(); + const { chatId } = useGlobalAgent(); + const gate = useAgentGate(); + const ready = gate.status === "ready"; + + useEffect(() => { + if (ready && chatId) { + router.replace(`/agent/${chatId}`); + } + }, [ready, chatId, router]); + + return ; +} diff --git a/apps/dashboard/app/(main)/agent/layout.tsx b/apps/dashboard/app/(main)/agent/layout.tsx new file mode 100644 index 000000000..82cd8a247 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from "react"; +import { GlobalAgentProvider } from "@/components/agent/global-agent-provider"; + +export default function AgentLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/dashboard/app/(main)/agent/page.tsx b/apps/dashboard/app/(main)/agent/page.tsx new file mode 100644 index 000000000..8a91dbd83 --- /dev/null +++ b/apps/dashboard/app/(main)/agent/page.tsx @@ -0,0 +1,5 @@ +import { GlobalAgentRedirect } from "./_components/global-agent-redirect"; + +export default function AgentIndexPage() { + return ; +} diff --git a/apps/dashboard/app/(main)/layout.tsx b/apps/dashboard/app/(main)/layout.tsx index df1269d0e..360797178 100644 --- a/apps/dashboard/app/(main)/layout.tsx +++ b/apps/dashboard/app/(main)/layout.tsx @@ -1,6 +1,5 @@ import { publicConfig } from "@databuddy/env/public"; import { FeedbackPrompt } from "@/components/feedback-prompt"; -import { GlobalAgentProvider } from "@/components/agent/global-agent-provider"; import { isDashboardE2E } from "@/lib/e2e-mode"; import { Sidebar } from "@/components/layout/sidebar"; import { @@ -48,20 +47,18 @@ export default function MainLayout({ - -
- }> - - - - -
- {children} -
-
- -
-
+
+ }> + + + + +
+ {children} +
+
+ +
diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx index 81b5de6b7..371aa86ad 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx @@ -1,4 +1,3 @@ -import { ChatProvider } from "@/contexts/chat-context"; import { AgentPageClient } from "../_components/agent-page-client"; interface Props { @@ -8,9 +7,5 @@ interface Props { export default async function AgentPage(props: Props) { const { id, chatId } = await props.params; - return ( - - - - ); + return ; } diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx index 26aba8bfc..5e1912820 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-client.tsx @@ -1,6 +1,11 @@ "use client"; -import { AgentPageContent } from "./agent-page-content"; +import { Skeleton } from "@databuddy/ui"; +import { useRouter } from "next/navigation"; +import { AgentWorkspace } from "@/components/agent/agent-workspace"; +import { clearLastChatId } from "@/components/agent/hooks/use-chat-db"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { ChatProvider } from "@/contexts/chat-context"; interface AgentPageClientProps { chatId: string; @@ -8,9 +13,40 @@ interface AgentPageClientProps { } export function AgentPageClient({ chatId, websiteId }: AgentPageClientProps) { + const router = useRouter(); + const { activeOrganizationId, isLoading } = useOrganizationsContext(); + const basePath = `/websites/${websiteId}/agent`; + + if (isLoading) { + return ( +
+ + +
+ ); + } + return ( -
- -
+ + { + if (nextChatId) { + router.push(`${basePath}/${nextChatId}`); + } else { + clearLastChatId(websiteId); + router.push(basePath); + } + }} + onNewChat={(newChatId) => router.push(`${basePath}/${newChatId}`)} + onSelectChat={(nextChatId) => router.push(`${basePath}/${nextChatId}`)} + organizationId={activeOrganizationId} + /> + ); } diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx deleted file mode 100644 index e53db476a..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-page-content.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { AgentChatSurface } from "@/components/agent/agent-chat-surface"; -import { AgentCreditBalance } from "@/components/agent/agent-credit-balance"; -import { ChatHistory } from "@/components/agent/chat-history"; -import { NewChatButton } from "@/components/agent/new-chat-button"; -import { TopBar } from "@/components/layout/top-bar"; -import { orpc } from "@/lib/orpc"; -import { Tooltip } from "@databuddy/ui"; -import { Avatar } from "@databuddy/ui/client"; - -interface AgentPageContentProps { - chatId: string; - websiteId: string; -} - -export function AgentPageContent({ chatId, websiteId }: AgentPageContentProps) { - const { data: chatMeta, isPending: isChatMetaPending } = useQuery({ - ...orpc.agentChats.get.queryOptions({ input: { id: chatId } }), - refetchOnWindowFocus: false, - staleTime: Number.POSITIVE_INFINITY, - }); - - const chatTitle = chatMeta?.title?.trim() ?? ""; - const showChatTitle = chatTitle.length > 0; - const chatTitleDisplayed = - chatTitle === "" - ? chatTitle - : `${chatTitle.slice(0, 1).toLocaleUpperCase()}${chatTitle.slice(1)}`; - - return ( -
-
- -
- -
-

- Databunny -

- {!showChatTitle && ( - - Alpha - - )} - {isChatMetaPending ? ( - <> - - - - ) : ( - showChatTitle && ( - <> - - -

- {chatTitleDisplayed} -

- - ) - )} -
-
-
- - - - -
- -
-
- -
- - -
-
- ); -} diff --git a/apps/dashboard/components/agent/agent-atoms.ts b/apps/dashboard/components/agent/agent-atoms.ts index 636484dd3..8346db6bd 100644 --- a/apps/dashboard/components/agent/agent-atoms.ts +++ b/apps/dashboard/components/agent/agent-atoms.ts @@ -3,6 +3,14 @@ import { atomWithStorage } from "jotai/utils"; export const agentInputAtom = atom(""); +export interface AgentMention { + domain?: string; + id: string; + label: string; +} + +export const agentMentionsAtom = atom([]); + export type AgentThinking = "off" | "low" | "medium" | "high"; export type AgentTier = "quick" | "balanced" | "deep"; diff --git a/apps/dashboard/components/agent/agent-chat-surface.tsx b/apps/dashboard/components/agent/agent-chat-surface.tsx index bdd931d8e..86dd40dec 100644 --- a/apps/dashboard/components/agent/agent-chat-surface.tsx +++ b/apps/dashboard/components/agent/agent-chat-surface.tsx @@ -20,8 +20,11 @@ import { LightningIcon, TableIcon, } from "@databuddy/ui/icons"; +import { useSetAtom } from "jotai"; +import { agentMentionsAtom } from "./agent-atoms"; import { AgentInput } from "./agent-input"; import { AgentMessages } from "./agent-messages"; +import { AGENT_COMMANDS } from "./agent-commands"; import { setLastChatId } from "./hooks/use-chat-db"; import { Avatar } from "@databuddy/ui/client"; import { Button, Skeleton } from "@databuddy/ui"; @@ -31,7 +34,8 @@ interface AgentChatSurfaceProps { chatId: string; className?: string; contentClassName?: string; - websiteId: string; + defaultWebsiteId?: string; + organizationId: string | null; } const FALLBACK_ICONS = [ @@ -43,20 +47,40 @@ const FALLBACK_ICONS = [ const LOADING_DELAY_MS = 250; +const DEFAULT_PROMPTS = AGENT_COMMANDS.filter( + (command) => command.action !== "clear" +) + .slice(0, 4) + .map((command) => ({ + label: command.title, + prompt: command.prompt, + source: "default" as const, + })); + export function AgentChatSurface({ autoSendPromptFromUrl = false, chatId, className, contentClassName, - websiteId, + defaultWebsiteId, + organizationId, }: AgentChatSurfaceProps) { + const lastChatScope = defaultWebsiteId ?? organizationId; + const setMentions = useSetAtom(agentMentionsAtom); + useEffect(() => { - setLastChatId(websiteId, chatId); - }, [websiteId, chatId]); + if (lastChatScope) { + setLastChatId(lastChatScope, chatId); + } + }, [lastChatScope, chatId]); + + useEffect(() => { + setMentions([]); + }, [chatId, setMentions]); const { messages, sendMessage } = useChat(); const { isRestoring, isEmpty } = useChatLoading(); - const { data: website } = useWebsite(websiteId); + const { data: website } = useWebsite(defaultWebsiteId ?? ""); const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -116,7 +140,7 @@ export function AgentChatSurface({ ) : null} @@ -164,15 +188,18 @@ function WelcomeState({ }: { domain: string | null; onPromptSelect: (text: string) => void; - websiteId: string; + websiteId?: string; }) { - const { data: prompts, isLoading } = useQuery({ + const { data: fetchedPrompts, isLoading } = useQuery({ ...orpc.agentChats.suggestedPrompts.queryOptions({ - input: { websiteId }, + input: { websiteId: websiteId ?? "" }, }), + enabled: Boolean(websiteId), staleTime: 5 * 60 * 1000, }); + const prompts = websiteId ? fetchedPrompts : DEFAULT_PROMPTS; + return (
@@ -186,11 +213,15 @@ function WelcomeState({

Meet Databunny

- Ask anything about{" "} - - {domain ?? "your"} - - 's analytics. + {domain ? ( + <> + Ask anything about{" "} + {domain}'s + analytics. + + ) : ( + "Ask anything about your analytics. Mention a site with @." + )}

diff --git a/apps/dashboard/components/agent/agent-input.tsx b/apps/dashboard/components/agent/agent-input.tsx index 3d2ffee22..06eca6001 100644 --- a/apps/dashboard/components/agent/agent-input.tsx +++ b/apps/dashboard/components/agent/agent-input.tsx @@ -17,23 +17,33 @@ import { } from "@databuddy/ui/icons"; import { useChat, usePendingQueue } from "@/contexts/chat-context"; import { cn } from "@/lib/utils"; +import { FaviconImage } from "@/components/analytics/favicon-image"; import { useBillingContext, useUsageFeature, } from "@/components/providers/billing-provider"; +import { type Website, useWebsitesLight } from "@/hooks/use-websites"; import { AGENT_TIERS, AGENT_THINKING_LEVELS, TIER_SUPPORTS_THINKING, + type AgentMention, type AgentTier, type AgentThinking, agentCreditShakeNonceAtom, agentInputAtom, + agentMentionsAtom, agentTierAtom, agentThinkingAtom, } from "./agent-atoms"; import { AgentCommandMenu } from "./agent-command-menu"; import { type AgentCommand, filterCommands } from "./agent-commands"; +import { AgentMentionMenu } from "./agent-mention-menu"; +import { + filterMentionWebsites, + getMentionQuery, + stripMentionQuery, +} from "./agent-mentions"; import { AgentTextSwitch, AGENT_INPUT_PLACEHOLDER_PHRASES, @@ -47,6 +57,8 @@ export function AgentInput() { const { messages: pendingMessages, removeAction } = usePendingQueue(); const isLoading = status === "streaming" || status === "submitted"; const [input, setInput] = useAtom(agentInputAtom); + const [mentions, setMentions] = useAtom(agentMentionsAtom); + const { websites } = useWebsitesLight(); const bumpCreditShake = useSetAtom(agentCreditShakeNonceAtom); const { balance, unlimited } = useUsageFeature("agent_credits"); const { customer, isLoading: billingLoading } = useBillingContext(); @@ -56,6 +68,8 @@ export function AgentInput() { const { formRef, onKeyDown } = useEnterSubmit(); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [commandsDismissed, setCommandsDismissed] = useState(false); + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); + const [mentionsDismissed, setMentionsDismissed] = useState(false); const [placeholderReplayKey, setPlaceholderReplayKey] = useState(0); const textareaRef = useRef(null); const replayFrameRef = useRef(null); @@ -101,8 +115,28 @@ export function AgentInput() { return filterCommands(query); }, [input]); + const mentionQuery = useMemo(() => getMentionQuery(input), [input]); + const mentionedIds = useMemo( + () => new Set(mentions.map((m) => m.id)), + [mentions] + ); + const mentionResults = useMemo(() => { + if (mentionQuery === null) { + return []; + } + return filterMentionWebsites(websites, mentionQuery, mentionedIds); + }, [mentionQuery, websites, mentionedIds]); + + const showMentions = + !(mentionsDismissed || isLoading) && mentionResults.length > 0; + const safeMentionIndex = + mentionResults.length === 0 + ? 0 + : Math.min(selectedMentionIndex, mentionResults.length - 1); + const showCommands = - !(commandsDismissed || isLoading) && filteredCommands.length > 0; + !(commandsDismissed || isLoading || showMentions) && + filteredCommands.length > 0; const safeCommandIndex = filteredCommands.length === 0 ? 0 @@ -139,9 +173,66 @@ export function AgentInput() { setCommandsDismissed(true); }; + const selectMention = useCallback( + (website: Website) => { + setMentions((prev) => + prev.some((m) => m.id === website.id) + ? prev + : [ + ...prev, + { + id: website.id, + label: website.name ?? website.domain, + domain: website.domain, + }, + ] + ); + setInput(stripMentionQuery(inputSyncRef.current)); + setSelectedMentionIndex(0); + requestAnimationFrame(() => textareaRef.current?.focus()); + }, + [setMentions, setInput] + ); + + const removeMention = (id: string) => { + setMentions((prev) => prev.filter((m) => m.id !== id)); + }; + const handleMessageKeyDown = ( event: React.KeyboardEvent ) => { + if (showMentions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedMentionIndex((prev) => (prev + 1) % mentionResults.length); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedMentionIndex( + (prev) => (prev - 1 + mentionResults.length) % mentionResults.length + ); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setMentionsDismissed(true); + return; + } + if ( + (event.key === "Enter" && + !event.shiftKey && + !event.nativeEvent.isComposing) || + event.key === "Tab" + ) { + event.preventDefault(); + const target = mentionResults[safeMentionIndex]; + if (target) { + selectMention(target); + } + return; + } + } if (showCommands) { if (event.key === "ArrowDown") { event.preventDefault(); @@ -191,8 +282,54 @@ export function AgentInput() { setCommandsDismissed(false); } setSelectedCommandIndex(0); + setMentionsDismissed(false); + setSelectedMentionIndex(0); }; + const inputSurface = ( +
+
+
+ +
+