import { type GitHubContributor, getCommitActivityStats, getCommitCoAuthors, getCommitsSince, getContributors, getReleases, getStargazersPage, getUser, getUserById, } from "./github"; import { getDownloadsRange } from "./npm"; export type PackageInfo = { name: string; description: string; category: PackageCategory; deprecated?: boolean; }; export type PackageCategory = | "core" | "tooling" | "cloud" | "frameworks" | "protocols" | "platforms" | "ui" | "effects" | "mcp" | "observability" | "deprecated"; export const PACKAGE_CATEGORIES: Record< PackageCategory, { label: string; description: string } > = { core: { label: "Core", description: "Runtime primitives every distribution shares.", }, tooling: { label: "Tooling & CLI", description: "Scaffolding and build tooling.", }, cloud: { label: "Cloud & streaming", description: "Hosted persistence and stream transports.", }, frameworks: { label: "Framework adapters", description: "Drop-in bindings for popular AI SDKs.", }, protocols: { label: "Protocol adapters", description: "Open agent protocols, ready to wire up.", }, platforms: { label: "Platform bindings", description: "Run anywhere React runs.", }, ui: { label: "UI & rendering", description: "Markdown, rich composers, and devtools.", }, effects: { label: "Effects & primitives", description: "Standalone packages we built along the way.", }, mcp: { label: "MCP & agents", description: "Tooling for MCP hosts and agent runtimes.", }, observability: { label: "Observability", description: "Trace, debug, and measure assistants.", }, deprecated: { label: "Deprecated", description: "No longer maintained. Kept on npm for existing installs.", }, }; export const PACKAGES: PackageInfo[] = [ { name: "@assistant-ui/react", description: "TypeScript/React library for AI chat.", category: "core", }, { name: "@assistant-ui/core", description: "Framework-agnostic core runtime.", category: "core", }, { name: "@assistant-ui/store", description: "Tap-based state management.", category: "core", }, { name: "@assistant-ui/tap", description: "Zero-dependency reactive primitives.", category: "core", }, { name: "assistant-ui", description: "CLI for assistant-ui.", category: "tooling", }, { name: "create-assistant-ui", description: "Scaffold an assistant-ui app in one command.", category: "tooling", }, { name: "@assistant-ui/x-buildutils", description: "Shared build utilities for the monorepo.", category: "tooling", }, { name: "@assistant-ui/next", description: "Next.js plugin for the generative UI compiler.", category: "tooling", }, { name: "@assistant-ui/vite", description: "Vite plugin for the generative UI compiler.", category: "tooling", }, { name: "@assistant-ui/x-generative-compiler", description: 'Framework-agnostic "use generative" compiler.', category: "tooling", }, { name: "assistant-cloud", description: "Hosted backend for assistant-ui.", category: "cloud", }, { name: "assistant-stream", description: "Streaming utilities for AI assistants.", category: "cloud", }, { name: "@assistant-ui/cloud-ai-sdk", description: "AI SDK hooks with assistant-cloud persistence.", category: "cloud", }, { name: "@assistant-ui/react-ai-sdk", description: "Vercel AI SDK adapter.", category: "frameworks", }, { name: "@assistant-ui/eve", description: "Eve runtime adapter.", category: "frameworks", }, { name: "@assistant-ui/react-langgraph", description: "LangGraph adapter.", category: "frameworks", }, { name: "@assistant-ui/react-langchain", description: "LangChain useStream adapter.", category: "frameworks", }, { name: "@assistant-ui/react-google-adk", description: "Google ADK adapter.", category: "frameworks", }, { name: "@assistant-ui/react-opencode", description: "OpenCode runtime adapter.", category: "frameworks", }, { name: "@assistant-ui/react-a2a", description: "A2A v1.0 agent-to-agent protocol adapter.", category: "protocols", }, { name: "@assistant-ui/react-ag-ui", description: "AG-UI protocol adapter.", category: "protocols", }, { name: "@assistant-ui/react-data-stream", description: "Generic data stream adapter.", category: "protocols", }, { name: "@assistant-ui/react-native", description: "React Native bindings.", category: "platforms", }, { name: "@assistant-ui/react-ink", description: "Terminal UI bindings via Ink.", category: "platforms", }, { name: "@assistant-ui/react-markdown", description: "Streaming-aware markdown renderer.", category: "ui", }, { name: "@assistant-ui/react-streamdown", description: "Streamdown-based markdown rendering.", category: "ui", }, { name: "@assistant-ui/react-syntax-highlighter", description: "Syntax highlighting for assistant-ui.", category: "ui", }, { name: "@assistant-ui/react-ink-markdown", description: "Markdown for the terminal distribution.", category: "ui", }, { name: "@assistant-ui/react-lexical", description: "Lexical composer with @-mention support.", category: "ui", }, { name: "@assistant-ui/react-hook-form", description: "React Hook Form integration.", category: "ui", }, { name: "@assistant-ui/react-devtools", description: "Inspect runtime state in the browser.", category: "ui", }, { name: "tw-glass", description: "Tailwind v4 plugin for glass refraction effects.", category: "effects", }, { name: "tw-shimmer", description: "Tailwind v4 plugin for shimmer effects.", category: "effects", }, { name: "heat-graph", description: "Headless React components for activity heatmaps.", category: "effects", }, { name: "safe-content-frame", description: "Secure iframe rendering for untrusted content.", category: "effects", }, { name: "@assistant-ui/mcp-docs-server", description: "MCP server exposing assistant-ui docs.", category: "mcp", }, { name: "@assistant-ui/agent-launcher", description: "Launch Claude Code with bundled plugins.", category: "mcp", }, { name: "@assistant-ui/react-mcp", description: "MCP server configuration and connection primitives.", category: "mcp", }, { name: "@assistant-ui/react-o11y", description: "Observability primitives for assistants.", category: "observability", }, { name: "@assistant-ui/react-edge", description: "Legacy edge runtime, superseded by the AI SDK adapter.", category: "deprecated", deprecated: true, }, { name: "@assistant-ui/styles", description: "Prebuilt styles for non-Tailwind users.", category: "deprecated", deprecated: true, }, { name: "@assistant-ui/react-trieve", description: "Trieve search integration.", category: "deprecated", deprecated: true, }, { name: "@assistant-ui/react-playground", description: "Standalone playground runtime.", category: "deprecated", deprecated: true, }, ]; export type PackageDownloads = { weekly: number; series: number[]; monthly: number; prevMonthly: number; }; export type NpmDownloads = { totalWeekly: number; perPackage: Record; }; export type TimelinePoint = { date: string; value: number; }; export type Contributor = { login: string; avatarUrl: string; htmlUrl: string; contributions: number; }; export type ActivityPoint = { date: string; count: number; }; export async function fetchCommitActivity( revalidate?: number, ): Promise { const stats = await getCommitActivityStats(revalidate); if (stats && stats.length > 0) { const points: ActivityPoint[] = []; for (const week of stats) { for (let i = 0; i < 7; i++) { const ms = (week.week + i * 86_400) * 1000; const d = new Date(ms); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); points.push({ date: `${y}-${m}-${day}`, count: week.days[i] ?? 0 }); } } return points; } const since = new Date(); since.setUTCDate(since.getUTCDate() - 365); const items = await getCommitsSince( since.toISOString(), undefined, revalidate, ); const counts = new Map(); for (const c of items) { const iso = c.commit?.author?.date ?? c.commit?.committer?.date; if (!iso) continue; const day = iso.slice(0, 10); counts.set(day, (counts.get(day) ?? 0) + 1); } return Array.from(counts.entries()).map(([date, count]) => ({ date, count })); } export async function fetchReleaseActivity( revalidate?: number, ): Promise { const cutoff = Date.now() - 365 * 86_400_000; const releases = await getReleases(undefined, revalidate); const counts = new Map(); for (const r of releases) { const iso = r.published_at ?? r.created_at; if (new Date(iso).getTime() < cutoff) continue; const day = iso.slice(0, 10); counts.set(day, (counts.get(day) ?? 0) + 1); } return Array.from(counts.entries()).map(([date, count]) => ({ date, count })); } const isBot = (login: string, type?: string) => type === "Bot" || /\[bot\]$/i.test(login); const toContributor = (c: GitHubContributor): Contributor => ({ login: c.login, avatarUrl: c.avatar_url, htmlUrl: c.html_url, contributions: c.contributions, }); export async function fetchContributors( revalidate?: number, ): Promise { const raw = await getContributors(undefined, revalidate); if (raw === null) return null; return raw.filter((c) => !isBot(c.login, c.type)).map(toContributor); } /* Claude has no GitHub account, so it never resolves as a "Bot"; it is matched by the co-author email every model variant shares. */ const CLAUDE_CO_AUTHOR_EMAIL = "noreply@anthropic.com"; export async function fetchBotCoAuthors( revalidate?: number, ): Promise { const coAuthors = await getCommitCoAuthors(revalidate); if (coAuthors === null) return []; let claudeCount = 0; const accounts = new Map< string, { id: number | null; login: string | null; count: number } >(); for (const author of coAuthors) { if (author.email === CLAUDE_CO_AUTHOR_EMAIL) { claudeCount += author.count; continue; } if (author.id == null && author.login == null) continue; const key = author.id != null ? `id:${author.id}` : `login:${author.login!.toLowerCase()}`; const entry = accounts.get(key); if (entry) entry.count += author.count; else accounts.set(key, { id: author.id, login: author.login, count: author.count, }); } const resolved = await Promise.all( Array.from(accounts.values()).map(async ({ id, login, count }) => { const user = id != null ? await getUserById(id, revalidate) : await getUser(login!, revalidate); if (!user || user.type !== "Bot") return null; return { login: user.login, avatarUrl: user.avatarUrl, htmlUrl: user.htmlUrl, contributions: count, } satisfies Contributor; }), ); // distinct app accounts can resolve to the same login (e.g. Copilot reviewer + swe-agent), so merge by login. const byLogin = new Map(); for (const bot of resolved) { if (!bot) continue; const entry = byLogin.get(bot.login); if (entry) entry.contributions += bot.contributions; else byLogin.set(bot.login, { ...bot }); } const result = Array.from(byLogin.values()); if (claudeCount > 0) { const anthropic = await getUser("anthropics", revalidate); result.push({ login: "Claude", avatarUrl: anthropic?.avatarUrl ?? "/icons/anthropic.svg", htmlUrl: "https://claude.com", contributions: claudeCount, }); } return result.sort((a, b) => b.contributions - a.contributions); } const EMPTY_DOWNLOADS: PackageDownloads = { weekly: 0, series: [], monthly: 0, prevMonthly: 0, }; const sum = (arr: number[]) => arr.reduce((acc, n) => acc + n, 0); async function fetchPackageDownloadRange( name: string, revalidate?: number, ): Promise { const today = new Date(); const end = today.toISOString().slice(0, 10); const start = new Date(today); start.setUTCDate(today.getUTCDate() - 60); const startStr = start.toISOString().slice(0, 10); const downloads = await getDownloadsRange(name, startStr, end, revalidate); const all = downloads.map((d) => d.downloads); if (all.length === 0) return EMPTY_DOWNLOADS; const last60 = all.slice(-60); const last30 = last60.slice(-30); const prior30 = last60.slice(-60, -30); const last7 = last60.slice(-7); return { weekly: sum(last7), series: last30, monthly: sum(last30), prevMonthly: sum(prior30), }; } export async function fetchNpmDownloads( revalidate?: number, ): Promise { const entries = await Promise.all( PACKAGES.filter((pkg) => !pkg.deprecated).map( async (pkg) => [ pkg.name, await fetchPackageDownloadRange(pkg.name, revalidate), ] as const, ), ); const perPackage: Record = {}; let totalWeekly = 0; for (const [name, downloads] of entries) { perPackage[name] = downloads; totalWeekly += downloads.weekly; } return { totalWeekly, perPackage }; } function currentMonthKey(): string { return new Date().toISOString().slice(0, 7); } export const TIMELINE_PACKAGES = [ "@assistant-ui/react", "@assistant-ui/react-ai-sdk", "@assistant-ui/react-markdown", "@assistant-ui/react-langgraph", "assistant-stream", ] as const; export type TimelineSeries = { series: { key: string; pkg: string; label: string; chartIndex: number; }[]; data: { date: string; [key: string]: number | string }[]; projectedMonth?: string; }; export async function fetchTimelineSeries( packages: readonly string[], revalidate?: number, ): Promise { const fetched = await Promise.all( packages.map(async (pkg, idx) => ({ key: `s${idx}`, pkg, label: pkg.replace(/^@assistant-ui\//, "").replace(/^assistant-/, ""), chartIndex: (idx % 5) + 1, points: await fetchDownloadsTimeline(pkg, revalidate), })), ); const monthsSet = new Set(); for (const { points } of fetched) { for (const p of points) monthsSet.add(p.date); } const months = Array.from(monthsSet).sort(); const cutoff = currentMonthKey(); const data = months.map((date) => { const isProjected = date === cutoff; const row: { date: string; [key: string]: number | string } = { date }; for (const { key, points } of fetched) { const point = points.find((p) => p.date === date); const value = point?.value ?? 0; if (!isProjected) row[key] = value; } return row; }); const projectedIndex = data.findIndex((r) => r.date === cutoff); if (projectedIndex >= 1) { for (let idx = projectedIndex - 1; idx <= projectedIndex; idx++) { const row = data[idx]!; const rowDate = row.date as string; for (const { key, points } of fetched) { const val = points.find((p) => p.date === rowDate)?.value ?? 0; row[`${key}_proj`] = val; } } } const series = fetched.map(({ key, pkg, label, chartIndex }) => ({ key, pkg, label, chartIndex, })); return { series, data, ...(projectedIndex >= 0 ? { projectedMonth: cutoff } : {}), }; } export async function fetchDownloadsTimeline( name: string, revalidate?: number, ): Promise { // npm's last-year endpoint stops at the last complete day, so it omits the // in-flight month entirely; an explicit range through today keeps it. const today = new Date(); const end = today.toISOString().slice(0, 10); const start = new Date( Date.UTC(today.getUTCFullYear() - 1, today.getUTCMonth(), 1), ) .toISOString() .slice(0, 10); const downloads = await getDownloadsRange(name, start, end, revalidate); if (!downloads.length) return []; const cutoff = currentMonthKey(); type MonthBucket = { sum: number; dailies: { day: string; downloads: number }[]; }; const byMonth = new Map(); for (const point of downloads) { const month = point.day.slice(0, 7); const entry = byMonth.get(month) ?? { sum: 0, dailies: [] }; entry.sum += point.downloads; entry.dailies.push({ day: point.day, downloads: point.downloads }); byMonth.set(month, entry); } const sorted = Array.from(byMonth.entries()).sort(([a], [b]) => a.localeCompare(b), ); const fullMonths = sorted.filter(([k]) => k !== cutoff); const lastFullMonth = fullMonths[fullMonths.length - 1]; const priorFullMonth = fullMonths[fullMonths.length - 2]; return sorted.map(([date, bucket]) => { if (date !== cutoff || bucket.dailies.length === 0) { return { date, value: bucket.sum }; } return { date, value: projectInflightMonth( date, bucket, lastFullMonth?.[1].sum, priorFullMonth?.[1].sum, ), }; }); } function projectInflightMonth( date: string, bucket: { sum: number; dailies: { day: string; downloads: number }[] }, lastFullMonthSum: number | undefined, priorFullMonthSum: number | undefined, ): number { // npm aggregation lags 1-2 days; trailing days under-report and would drag the daily average down. const TRAILING_LAG_DAYS = 2; const dailies = [...bucket.dailies].sort((a, b) => a.day.localeCompare(b.day), ); const stable = dailies.slice( 0, Math.max(1, dailies.length - TRAILING_LAG_DAYS), ); const stableSum = stable.reduce((s, d) => s + d.downloads, 0); const stableDays = stable.length; const totalDays = new Date( Number(date.slice(0, 4)), Number(date.slice(5, 7)), 0, ).getDate(); const linear = (stableSum / stableDays) * totalDays; if (!lastFullMonthSum || !priorFullMonthSum || priorFullMonthSum === 0) { return Math.round(linear); } // cap growth so an outlier month doesn't produce a runaway projection. const growth = Math.max( -0.3, Math.min(1.0, lastFullMonthSum / priorFullMonthSum - 1), ); const trend = lastFullMonthSum * (1 + growth); // early in the month trust the trend (little data); late, trust observed. const elapsedFraction = stableDays / totalDays; const blended = elapsedFraction * linear + (1 - elapsedFraction) * trend; return Math.round(blended); } export async function fetchStarHistory( totalStars: number, revalidate?: number, ): Promise { try { const first = await getStargazersPage(1, revalidate); if (first.data.length === 0) return []; const lastPage = first.lastPage ?? Math.max(1, Math.ceil(totalStars / 100)); const samples = new Set([1, lastPage]); const target = 12; if (lastPage > 2) { const step = (lastPage - 1) / (target - 1); for (let i = 1; i < target - 1; i++) { samples.add(Math.max(2, Math.round(1 + i * step))); } } const pages = Array.from(samples) .filter((p) => p >= 1 && p <= lastPage) .sort((a, b) => a - b); const fetched = await Promise.all( pages.map(async (page) => { if (page === 1) return { page, data: first.data }; const result = await getStargazersPage(page, revalidate); return { page, data: result.data }; }), ); const anchors: TimelinePoint[] = fetched .filter(({ data }) => data.length > 0) .map(({ page, data }) => ({ date: data[0]!.starred_at, value: (page - 1) * 100 + 1, })) .sort((a, b) => a.date.localeCompare(b.date)) .filter((p, i, arr) => i === 0 || p.value > arr[i - 1]!.value); // page sampling stops at the first star on the last page; append (now, totalStars) so the chart reaches today. const now = new Date().toISOString(); const lastAnchor = anchors[anchors.length - 1]; if ( !lastAnchor || (totalStars > lastAnchor.value && now > lastAnchor.date) ) { anchors.push({ date: now, value: totalStars }); } if (anchors.length < 2) return anchors; return resampleMonthly(anchors); } catch { return []; } } // page-based sampling clusters unevenly in time; resample on a monthly grid so the chart shows real acceleration, not straight segments. function resampleMonthly(anchors: TimelinePoint[]): TimelinePoint[] { const startMs = new Date(anchors[0]!.date).getTime(); const endAnchor = anchors[anchors.length - 1]!; const endMs = new Date(endAnchor.date).getTime(); const startDate = new Date(startMs); const cursor = new Date( Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() + 1, 1), ); const out: TimelinePoint[] = [ { date: anchors[0]!.date, value: anchors[0]!.value }, ]; let i = 0; while (cursor.getTime() <= endMs) { const t = cursor.getTime(); while ( i < anchors.length - 1 && new Date(anchors[i + 1]!.date).getTime() < t ) { i++; } const a = anchors[i]!; const b = anchors[Math.min(i + 1, anchors.length - 1)]!; const aT = new Date(a.date).getTime(); const bT = new Date(b.date).getTime(); const ratio = bT === aT ? 0 : Math.min(1, Math.max(0, (t - aT) / (bT - aT))); const value = Math.round(a.value + (b.value - a.value) * ratio); out.push({ date: cursor.toISOString(), value }); cursor.setUTCMonth(cursor.getUTCMonth() + 1); } // tail point so the line reaches today instead of the latest crossed month boundary. const tailExisting = out[out.length - 1]!; if ( new Date(endAnchor.date).getTime() > new Date(tailExisting.date).getTime() ) { out.push(endAnchor); } return out; } export function daysSince(isoDate: string): number { const start = new Date(isoDate).getTime(); const now = Date.now(); return Math.max(0, Math.floor((now - start) / 86_400_000)); }