diff --git a/apps/web/.env.example b/apps/web/.env.example index 5c26914520c..ce85936d01f 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -149,10 +149,14 @@ PLAIN_WEBHOOK_SECRET= ###### DUB.CO INTERNAL USE ONLY ###### ###################################### -# For storing vector embeddings (/api/support/chat) +# Docs / help center search index UPSTASH_VECTOR_REST_URL= UPSTASH_VECTOR_REST_TOKEN= +# Emoji search index +UPSTASH_VECTOR_EMOJI_REST_URL= +UPSTASH_VECTOR_EMOJI_REST_TOKEN= + # Slack integration SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= diff --git a/apps/web/app/api/ai/emoji-search/route.ts b/apps/web/app/api/ai/emoji-search/route.ts new file mode 100644 index 00000000000..c87d55a3399 --- /dev/null +++ b/apps/web/app/api/ai/emoji-search/route.ts @@ -0,0 +1,24 @@ +import { searchEmojis } from "@/lib/ai/search-emojis"; +import { withSession } from "@/lib/auth"; +import { assertRateLimit } from "@/lib/upstash/assert-rate-limit"; +import { RATELIMIT_POLICIES } from "@/lib/upstash/ratelimit-policies"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const emojiSearchSchema = z.object({ + query: z.string().trim().min(2).max(64), +}); + +// POST /api/ai/emoji-search – semantic emoji matches when name search misses +export const POST = withSession(async ({ req, session }) => { + const { query } = emojiSearchSchema.parse(await req.json()); + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.emojiSearch, + identifier: session.user.id, + }); + + const emojis = await searchEmojis(query); + + return NextResponse.json({ emojis }); +}); diff --git a/apps/web/lib/ai/search-emojis.ts b/apps/web/lib/ai/search-emojis.ts new file mode 100644 index 00000000000..93648f1ef29 --- /dev/null +++ b/apps/web/lib/ai/search-emojis.ts @@ -0,0 +1,52 @@ +import { emojiVectorIndex } from "@/lib/upstash/vector"; + +const MATCH_LIMIT = 18; +const MATCH_SCORE_FLOOR = 0.45; + +export type EmojiMatch = { + emoji: string; + label: string; +}; + +type EmojiVectorHit = { + score: number; + metadata?: { + emoji?: string; + label?: string; + } | null; +}; + +export function toEmojiMatches(hits: EmojiVectorHit[]): EmojiMatch[] { + const matches: EmojiMatch[] = []; + + for (const hit of hits) { + if (hit.score < MATCH_SCORE_FLOOR) continue; + + const emoji = hit.metadata?.emoji; + const label = hit.metadata?.label?.trim(); + + if (!emoji || !label) continue; + + matches.push({ emoji, label }); + + if (matches.length >= MATCH_LIMIT) break; + } + + return matches; +} + +export async function searchEmojis(query: string): Promise { + const normalized = query.trim().toLowerCase(); + + if (normalized.length < 2) { + return []; + } + + const hits = await emojiVectorIndex.query({ + data: normalized, + topK: MATCH_LIMIT, + includeMetadata: true, + }); + + return toEmojiMatches(hits); +} diff --git a/apps/web/lib/upstash/ratelimit-policies.ts b/apps/web/lib/upstash/ratelimit-policies.ts index f0693d3374c..49cc6966e47 100644 --- a/apps/web/lib/upstash/ratelimit-policies.ts +++ b/apps/web/lib/upstash/ratelimit-policies.ts @@ -330,4 +330,11 @@ export const RATELIMIT_POLICIES = { keyPrefix: "rl:domains:search-availability", message: "Don't DDoS me pls πŸ₯Ί", }, + + emojiSearch: { + attempts: 30, + window: "10 s", + keyPrefix: "rl:ai:emoji-search", + message: "You've been rate limited. Please try again later.", + }, } as const satisfies Record; diff --git a/apps/web/lib/upstash/vector.ts b/apps/web/lib/upstash/vector.ts index 8a3ebec27c3..66e386a7262 100644 --- a/apps/web/lib/upstash/vector.ts +++ b/apps/web/lib/upstash/vector.ts @@ -1,6 +1,14 @@ import { Index } from "@upstash/vector"; export const vectorIndex = new Index({ - url: process.env.UPSTASH_VECTOR_REST_URL!, - token: process.env.UPSTASH_VECTOR_REST_TOKEN!, + url: process.env.UPSTASH_VECTOR_REST_URL ?? "", + token: process.env.UPSTASH_VECTOR_REST_TOKEN ?? "", +}); + +export const emojiVectorIndex = new Index<{ + emoji: string; + label: string; +}>({ + url: process.env.UPSTASH_VECTOR_EMOJI_REST_URL ?? "", + token: process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN ?? "", }); diff --git a/apps/web/scripts/dev/upsert-emoji-embeddings.ts b/apps/web/scripts/dev/upsert-emoji-embeddings.ts new file mode 100644 index 00000000000..e81a21129ac --- /dev/null +++ b/apps/web/scripts/dev/upsert-emoji-embeddings.ts @@ -0,0 +1,102 @@ +import "dotenv-flow/config"; +import { toEmojiMatches } from "../../lib/ai/search-emojis"; +import { emojiVectorIndex } from "../../lib/upstash/vector"; + +const EMOJIBASE_DATA_URL = + "https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json"; +const UPSERT_BATCH_SIZE = 500; + +type EmojiVectorRecord = { + id: string; + data: string; + metadata: { + emoji: string; + label: string; + }; +}; + +async function loadEmojiRecords(): Promise { + const response = await fetch(EMOJIBASE_DATA_URL, { + headers: { "User-Agent": "Mozilla/5.0" }, + }); + + if (!response.ok) { + throw new Error("Failed to load emoji data"); + } + + const data: unknown = await response.json(); + + if (!Array.isArray(data)) { + throw new Error("Failed to load emoji data"); + } + + const records: EmojiVectorRecord[] = []; + const seen = new Set(); + + for (const item of data) { + if (!item || typeof item !== "object") continue; + + const record = item as Record; + const emoji = typeof record.emoji === "string" ? record.emoji : null; + const label = typeof record.label === "string" ? record.label.trim() : null; + const hexcode = typeof record.hexcode === "string" ? record.hexcode : null; + + if (!emoji || !label || !hexcode || seen.has(hexcode)) continue; + + const tags = Array.isArray(record.tags) + ? record.tags.filter( + (tag): tag is string => + typeof tag === "string" && + tag.length > 1 && + tag.toLowerCase() !== label.toLowerCase(), + ) + : []; + + seen.add(hexcode); + records.push({ + id: hexcode, + data: tags.length === 0 ? label : `${label} (${tags.join(", ")})`, + metadata: { emoji, label }, + }); + } + + return records; +} + +async function main() { + if ( + !process.env.UPSTASH_VECTOR_EMOJI_REST_URL || + !process.env.UPSTASH_VECTOR_EMOJI_REST_TOKEN + ) { + throw new Error( + "Set UPSTASH_VECTOR_EMOJI_REST_URL and UPSTASH_VECTOR_EMOJI_REST_TOKEN", + ); + } + + const records = await loadEmojiRecords(); + + for (let index = 0; index < records.length; index += UPSERT_BATCH_SIZE) { + const batch = records.slice(index, index + UPSERT_BATCH_SIZE); + await emojiVectorIndex.upsert(batch); + console.log( + `Upserted ${Math.min(index + batch.length, records.length)}/${records.length}`, + ); + } + + const query = "jurassic park"; + const hits = await emojiVectorIndex.query({ + data: query, + topK: 18, + includeMetadata: true, + }); + + console.log(`\n${query}`); + for (const match of toEmojiMatches(hits)) { + console.log(`${match.emoji} ${match.label}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/ui/shared/emoji-picker.tsx b/apps/web/ui/shared/emoji-picker.tsx index 05d3bdeefa3..d0411c312d8 100644 --- a/apps/web/ui/shared/emoji-picker.tsx +++ b/apps/web/ui/shared/emoji-picker.tsx @@ -1,12 +1,134 @@ +import type { EmojiMatch } from "@/lib/ai/search-emojis"; import { Button, Popover } from "@dub/ui"; import { FaceSmile } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; import { EmojiPicker as EmojiPickerBase } from "frimousse"; import { PropsWithChildren, + useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; +import { + DUB_EMOJI_MATCHES, + normalizeEmojiQuery, + useSemanticEmojiSearch, +} from "./use-semantic-emoji-search"; + +const EMOJI_COLUMNS = 9; + +const LOADING_MESSAGES = [ + "Discombobulating...", + "Percolating...", + "Conjuring...", + "Reticulating...", + "Vibing...", + "Finagling...", + "Noodling...", + "Simmering...", + "Baking...", +]; + +function EmojiMatchGrid({ + matches, + onSelect, +}: { + matches: EmojiMatch[]; + onSelect: (emoji: string) => void; +}) { + const rows = Array.from( + { length: Math.ceil(matches.length / EMOJI_COLUMNS) }, + (_, index) => + matches.slice(index * EMOJI_COLUMNS, (index + 1) * EMOJI_COLUMNS), + ); + + return ( +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((match) => ( + + ))} +
+ ))} +
+ ); +} + +function EmojiSkeleton({ className }: { className?: string }) { + return ( +
+ {Array.from({ length: 8 }, (_, row) => ( +
+ {Array.from({ length: EMOJI_COLUMNS }, (_, column) => ( +
+ +
+ ))} +
+ ))} +
+ ); +} + +function EmojiSearchFallback({ + search, + onSelect, +}: { + search: string; + onSelect: (emoji: string) => void; +}) { + const query = normalizeEmojiQuery(search); + const semantic = useSemanticEmojiSearch(query, query.length >= 2); + const [loadingMessage] = useState( + () => LOADING_MESSAGES[Math.floor(Math.random() * LOADING_MESSAGES.length)], + ); + + if (semantic.status === "loading") { + return ( + <> + {loadingMessage} + + + ); + } + + if (semantic.status !== "ready") { + return ( +
+ No emoji found. +
+ ); + } + + return ( + <> + Did you mean... + + + ); +} + +function EmojiSearchHeader({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} type EmojiPickerProps = PropsWithChildren<{ onSelect: (emoji: string) => void; @@ -35,6 +157,16 @@ export function EmojiPicker({ const openPopover = isControlled ? controlledOpen : internalOpen; const setOpenPopover = isControlled ? controlledSetOpen : setInternalOpen; const keyboardDismissRef = useRef(false); + const [search, setSearch] = useState(""); + const showDubEasterEgg = normalizeEmojiQuery(search) === "dub"; + const selectEmoji = (emoji: string) => { + onSelect(emoji); + setOpenPopover(false); + }; + + useEffect(() => { + if (!openPopover) setSearch(""); + }, [openPopover]); const anchorEl = anchorRect ? (
{ keyboardDismissRef.current = true; @@ -82,23 +214,41 @@ export function EmojiPicker({ }} content={
{ + handleBackspaceClose(event); + if (!showDubEasterEgg) return; + if ( + event.key === "Enter" && + event.target instanceof HTMLInputElement + ) { + event.preventDefault(); + event.stopPropagation(); + selectEmoji(DUB_EMOJI_MATCHES[0].emoji); + return; + } + if (event.key.startsWith("Arrow")) { + event.preventDefault(); + event.stopPropagation(); + } + }} > { - onSelect(emoji); - setOpenPopover(false); - }} + onEmojiSelect={({ emoji }) => selectEmoji(emoji)} > - + setSearch(event.target.value)} + className="border-border-default focus:border-border-default z-10 w-full border-0 border-b bg-white px-3 py-2.5 text-base outline-none placeholder:text-neutral-400 focus:ring-0 sm:rounded-t-lg sm:text-sm" + /> - - Loading… + + - - No emoji found. + + {({ search }) => ( + + )} (
diff --git a/apps/web/ui/shared/inline-emoji-menu.tsx b/apps/web/ui/shared/inline-emoji-menu.tsx index 00edc11e50d..c60911fa30f 100644 --- a/apps/web/ui/shared/inline-emoji-menu.tsx +++ b/apps/web/ui/shared/inline-emoji-menu.tsx @@ -21,6 +21,11 @@ import { useRef, useState, } from "react"; +import { + DUB_EMOJI_MATCHES, + normalizeEmojiQuery, + useSemanticEmojiSearch, +} from "./use-semantic-emoji-search"; type EmojiShortcode = { emoji: string; @@ -121,14 +126,34 @@ export const InlineEmojiAutocomplete = forwardRef< const menuRef = useRef(null); const query = token?.query ?? ""; - const results = useMemo( - () => - token && !dismissed && !suspended ? searchEmojiShortcodes(query) : [], - [token, dismissed, suspended, query], + const normalizedQuery = normalizeEmojiQuery(query); + const menuActive = Boolean(token && !dismissed && !suspended); + const showDub = menuActive && normalizedQuery === "dub"; + const localResults = useMemo( + () => (menuActive && !showDub ? searchEmojiShortcodes(query) : []), + [menuActive, showDub, query], + ); + const semantic = useSemanticEmojiSearch( + query, + menuActive && !showDub && localResults.length === 0, ); - const open = results.length > 0; - const activeIndexClamped = results.length - ? Math.min(activeIndex, results.length - 1) + const suggestions = showDub + ? DUB_EMOJI_MATCHES + : localResults.length > 0 + ? localResults.map((item) => ({ + emoji: item.emoji, + label: item.shortcode, + })) + : semantic.matches; + const loading = + menuActive && + !showDub && + localResults.length === 0 && + semantic.status === "loading"; + const open = + showDub || localResults.length > 0 || loading || suggestions.length > 0; + const activeIndexClamped = suggestions.length + ? Math.min(activeIndex, suggestions.length - 1) : 0; const activeOptionId = `${listboxId}-option-${activeIndexClamped}`; @@ -236,7 +261,9 @@ export const InlineEmojiAutocomplete = forwardRef< dom.setAttribute("aria-controls", listboxId); dom.setAttribute("aria-expanded", "true"); dom.setAttribute("aria-autocomplete", "list"); - dom.setAttribute("aria-activedescendant", activeOptionId); + if (suggestions.length > 0) { + dom.setAttribute("aria-activedescendant", activeOptionId); + } return () => { dom.removeAttribute("aria-controls"); @@ -244,10 +271,10 @@ export const InlineEmojiAutocomplete = forwardRef< dom.removeAttribute("aria-autocomplete"); dom.removeAttribute("aria-activedescendant"); }; - }, [open, editor, listboxId, activeOptionId]); + }, [open, editor, listboxId, activeOptionId, suggestions.length]); const selectIndex = (index: number) => { - const next = results[index]; + const next = suggestions[index]; if (!next || !editor || !token) return; editor @@ -264,9 +291,19 @@ export const InlineEmojiAutocomplete = forwardRef< onKeyDown: (event) => { if (!open || event.isComposing) return false; + if (suggestions.length === 0) { + if (event.key === "Escape") { + event.preventDefault(); + setDismissed(true); + return true; + } + + return false; + } + if (event.key === "ArrowDown") { event.preventDefault(); - const next = (activeIndexRef.current + 1) % results.length; + const next = (activeIndexRef.current + 1) % suggestions.length; activeIndexRef.current = next; setActiveIndex(next); return true; @@ -275,7 +312,8 @@ export const InlineEmojiAutocomplete = forwardRef< if (event.key === "ArrowUp") { event.preventDefault(); const next = - (activeIndexRef.current - 1 + results.length) % results.length; + (activeIndexRef.current - 1 + suggestions.length) % + suggestions.length; activeIndexRef.current = next; setActiveIndex(next); return true; @@ -288,7 +326,7 @@ export const InlineEmojiAutocomplete = forwardRef< event.preventDefault(); const index = Math.min( activeIndexRef.current, - Math.max(results.length - 1, 0), + Math.max(suggestions.length - 1, 0), ); selectIndex(index); return true; @@ -303,7 +341,7 @@ export const InlineEmojiAutocomplete = forwardRef< return false; }, }), - [open, results, editor, token], + [open, suggestions, editor, token], ); if (!open) return null; @@ -322,37 +360,59 @@ export const InlineEmojiAutocomplete = forwardRef< onMouseDown={(event) => event.preventDefault()} className="border-border-subtle bg-bg-default z-[60] flex max-h-52 w-max min-w-40 max-w-[min(18rem,calc(100vw-16px))] flex-col overflow-y-auto rounded-lg border p-1 shadow-sm" > - {results.map((item, index) => { - const selected = index === activeIndexClamped; - - return ( - - ); - })} + {loading ? ( + <> +
+ Loading emojis +
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ + +
+ ))} +
+ + ) : ( + suggestions.map((item, index) => { + const selected = index === activeIndexClamped; + + return ( + + ); + }) + )}
); diff --git a/apps/web/ui/shared/use-semantic-emoji-search.ts b/apps/web/ui/shared/use-semantic-emoji-search.ts new file mode 100644 index 00000000000..54d6f0b2cc0 --- /dev/null +++ b/apps/web/ui/shared/use-semantic-emoji-search.ts @@ -0,0 +1,126 @@ +"use client"; + +import type { EmojiMatch } from "@/lib/ai/search-emojis"; +import { useEffect, useState } from "react"; + +export const DUB_EMOJI_MATCHES: EmojiMatch[] = [ + { emoji: "🀝", label: "Partners" }, + { emoji: "πŸ’°", label: "Money" }, + { emoji: "πŸ’Έ", label: "Payout" }, + { emoji: "πŸ”—", label: "Link" }, + { emoji: "πŸ“±", label: "Mobile" }, + { emoji: "πŸš€", label: "Growth" }, + { emoji: "πŸ“Š", label: "Analytics" }, + { emoji: "🎯", label: "Conversion" }, + { emoji: "🌐", label: "Domain" }, + { emoji: "πŸ“ˆ", label: "Clicks" }, + { emoji: "🏷️", label: "Tag" }, + { emoji: "πŸ‘₯", label: "Customers" }, + { emoji: "πŸ’¬", label: "Messages" }, + { emoji: "🎁", label: "Reward" }, + { emoji: "πŸ†", label: "Bounty" }, + { emoji: "πŸ””", label: "Webhook" }, +]; + +const emojiSearchCache = new Map(); +const emojiSearchRequests = new Map>(); + +export function normalizeEmojiQuery(query: string) { + return query.trim().toLowerCase(); +} + +function fetchEmojiMatches(query: string) { + const cached = emojiSearchCache.get(query); + if (cached) return Promise.resolve(cached); + + const pending = emojiSearchRequests.get(query); + if (pending) return pending; + + const request = fetch("/api/ai/emoji-search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + }) + .then(async (response) => { + if (!response.ok) { + throw new Error("Emoji search failed"); + } + + const data = (await response.json()) as { emojis?: EmojiMatch[] }; + const matches = Array.isArray(data.emojis) + ? data.emojis.filter( + (match) => + !!match && + typeof match.emoji === "string" && + typeof match.label === "string", + ) + : []; + + emojiSearchCache.set(query, matches); + return matches; + }) + .finally(() => { + emojiSearchRequests.delete(query); + }); + + emojiSearchRequests.set(query, request); + return request; +} + +export function useSemanticEmojiSearch(query: string, enabled: boolean) { + const normalized = normalizeEmojiQuery(query); + const [result, setResult] = useState<{ + query: string; + matches: EmojiMatch[]; + } | null>(null); + const [failedQuery, setFailedQuery] = useState(null); + + useEffect(() => { + if (!enabled || normalized.length < 2 || emojiSearchCache.has(normalized)) { + return; + } + + let active = true; + const timeout = window.setTimeout(() => { + fetchEmojiMatches(normalized) + .then((matches) => { + if (!active) return; + setFailedQuery((current) => + current === normalized ? null : current, + ); + setResult({ query: normalized, matches }); + }) + .catch(() => { + if (!active) return; + setFailedQuery(normalized); + }); + }, 120); + + return () => { + active = false; + window.clearTimeout(timeout); + }; + }, [enabled, normalized]); + + if (!enabled || normalized.length < 2) { + return { status: "idle" as const, matches: [] as EmojiMatch[] }; + } + + const matches = + emojiSearchCache.get(normalized) ?? + (result?.query === normalized ? result.matches : undefined); + + if (!matches) { + if (failedQuery === normalized) { + return { status: "empty" as const, matches: [] as EmojiMatch[] }; + } + + return { status: "loading" as const, matches: [] as EmojiMatch[] }; + } + + if (matches.length === 0) { + return { status: "empty" as const, matches }; + } + + return { status: "ready" as const, matches }; +}