Merge branch 'dev'

This commit is contained in:
miloschwartz
2026-09-28 14:07:09 -04:00
42 changed files with 5932 additions and 108 deletions
+1
View File
@@ -5,3 +5,4 @@ node_modules
!.env.example
Dockerfile
.dockerignore
analytics-dashboard
+13
View File
@@ -35,3 +35,16 @@ NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net
# Set to 1 to disable PostHog / Rybbit / Reo in production builds
# NEXT_PUBLIC_DISABLE_ANALYTICS=
# ---------------------------------------------------------------------------
# Feedback, AI chat and search analytics. Stored by the Fossorial API; view them
# with the separate app in analytics-dashboard/. Unset = nothing is stored.
# ---------------------------------------------------------------------------
# Base URL of the Fossorial API (without /api/v1)
# FOSSORIAL_API_URL=http://localhost:3004
# The API's API_KEY (server-side only, never sent to browsers)
# FOSSORIAL_API_KEY=
# Page / answer votes and searches per minute per IP
# FEEDBACK_RATE_LIMIT_PER_MINUTE=60
+8
View File
@@ -14,6 +14,14 @@ Other scripts: `npm run build`, `npm start`, `npm run types:check`.
The site works without an AI key. To enable the assistant, set one provider key in `.env.local` (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY`). See `.env.example` for model, OpenAI-compatible endpoint, and rate-limit options.
## Analytics
Readers can rate each page (👍/👎) and each AI answer. Every AI chat and every search-bar query (with its result count, so searches that found nothing stand out) is recorded so you can see what people look for.
- Storage: the docs server forwards events to the Fossorial API (`/api/v1/docs-analytics`), which stores them in its Postgres and deletes anything older than 90 days. Set `FOSSORIAL_API_URL` and `FOSSORIAL_API_KEY` (the API's `API_KEY`) (see `.env.example`); without them nothing is stored and the site works as usual.
- Viewing: `analytics-dashboard/` is a separate app that reads the API's database directly. See its README.
- Readers are anonymous: votes carry a random id kept in the browser's localStorage, nothing else.
## Writing docs
- Pages live in `content/docs/**/*.mdx`. The URL is the file path.
+7
View File
@@ -0,0 +1,7 @@
# Copy to .env.local.
# Postgres the Fossorial API writes docs analytics to.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/api
# Docs site the page paths link to
DOCS_SITE_URL=https://docs.pangolin.net
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/.next/
*.tsbuildinfo
next-env.d.ts
.env
.env*.local
+1
View File
@@ -0,0 +1 @@
24
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+20
View File
@@ -0,0 +1,20 @@
# Docs analytics dashboard
A small Next.js app for reviewing what readers do on the docs site: page 👍/👎, AI chats and their 👍/👎, and search-bar queries, including those that found nothing.
**Where the data comes from:** the docs site forwards events to the Fossorial API (`/api/v1/docs-analytics`), which stores them in its Postgres (`docs*` tables, migrations in the api repo). This app reads those tables directly with Drizzle and never writes.
**Retention:** the API deletes docs analytics older than 90 days (`src/controllers/docsAnalytics/retention.ts` in the api repo), so the dashboard's longest range is 90 days (`RETENTION_DAYS` in `src/lib/range.ts`). Change both together.
## Run it
```bash
cd analytics-dashboard
npm install
cp .env.example .env.local # set DATABASE_URL to the API's Postgres
npm run dev # http://127.0.0.1:3005
```
- Point `DATABASE_URL` at the same database the API uses (for local development, the api repo's `docker-compose.postgres.yml`). A read-only role is enough; `.env.example` has the grants.
- `DOCS_SITE_URL` is where page links go (defaults to https://docs.pangolin.net).
- There is **no login**. The dev server only listens on 127.0.0.1. Add auth before deploying it anywhere.
+10
View File
@@ -0,0 +1,10 @@
import { fileURLToPath } from 'node:url';
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
// this app lives inside the docs repo but is its own project (own lockfile)
turbopack: { root: fileURLToPath(new URL('.', import.meta.url)) },
};
export default config;
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "pangolin-docs-analytics-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -H 127.0.0.1 -p 3005",
"build": "next build",
"start": "next start -H 127.0.0.1 -p 3005",
"types:check": "next typegen && tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"next": "^16.3.5",
"pg": "^8.23.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@types/node": "^26.6.3",
"@types/pg": "^8.23.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
@@ -0,0 +1,91 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { connection } from 'next/server';
import { cn } from '@/lib/cn';
import { getThread } from '@/lib/queries';
import { formatDateTime } from '@/lib/range';
import { Card } from '@/components/ui';
import { MessageMarkdown } from '@/components/message-markdown';
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
function toolLabel(tool: { name: string; input?: unknown }) {
const input = (tool.input ?? {}) as Record<string, unknown>;
const arg = input.query ?? input.path;
return typeof arg === 'string' ? `${tool.name}: ${arg}` : tool.name;
}
export default async function ChatPage(props: PageProps<'/chats/[id]'>) {
await connection();
const { id } = await props.params;
const thread = await getThread(id);
if (!thread) notFound();
return (
<div className="flex flex-col gap-4">
<Link href="/#chats" className="text-sm text-fd-muted-foreground hover:text-fd-foreground">
← All chats
</Link>
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold">AI chat</h1>
<p className="text-sm text-fd-muted-foreground">
{formatDateTime(thread.createdAt)}
{thread.page && (
<>
{' · started on '}
<a href={`${docsUrl}${thread.page}`} target="_blank" rel="noreferrer" className="underline underline-offset-2">
{thread.page}
</a>
</>
)}
{thread.visitorId && <> · visitor {thread.visitorId.slice(0, 8)}</>}
</p>
</div>
<div className="flex flex-col gap-3">
{thread.messages.map((m) => (
<Card
key={m.id}
id={m.id}
className={cn(
'scroll-mt-4 target:ring-2 target:ring-fd-ring',
m.role === 'user' && 'bg-fd-secondary',
)}
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className={cn('text-sm font-medium', m.role === 'assistant' && 'text-fd-primary')}>
{m.role === 'user' ? 'User' : 'Pangolin AI'}
</p>
<div className="flex items-center gap-3 text-xs text-fd-muted-foreground">
{m.vote === 1 && <span className="rounded-full border px-2 py-0.5 text-fd-foreground">👍 Helpful</span>}
{m.vote === -1 && (
<span className="rounded-full border px-2 py-0.5 text-fd-foreground">👎 Not helpful</span>
)}
<span>{formatDateTime(m.createdAt)}</span>
</div>
</div>
{m.tools.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{m.tools.map((t, i) => (
<code key={i} className="max-w-full truncate rounded border bg-fd-background px-1.5 py-0.5 text-xs">
{toolLabel(t)}
</code>
))}
</div>
)}
{m.role === 'user' ? (
<p className="text-sm whitespace-pre-wrap break-words">{m.content}</p>
) : m.content ? (
<div className="prose text-sm max-w-none">
<MessageMarkdown text={m.content} />
</div>
) : (
<p className="text-sm text-fd-muted-foreground">(no text, the answer was stopped or failed)</p>
)}
</Card>
))}
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/*
* Same palette and token names as the docs site (`--color-fd-*`), so the markup reads the
* same in both apps. Dark mode follows the OS.
*/
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--color-fd-background: #faf9f2;
--color-fd-foreground: #202020;
--color-fd-muted-foreground: #6b6a63;
--color-fd-popover: #faf9f2;
--color-fd-popover-foreground: #202020;
--color-fd-card: #f2f0e7;
--color-fd-border: #bbbbbb;
--color-fd-primary: #202020;
--color-fd-secondary: #f2f0e7;
--color-fd-accent: #e8e7e5;
--color-fd-ring: #bbbbbb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-fd-background: #161614;
--color-fd-foreground: #ecebe6;
--color-fd-muted-foreground: #a3a199;
--color-fd-popover: #1b1a18;
--color-fd-popover-foreground: #ecebe6;
--color-fd-card: #1f1e1b;
--color-fd-border: #2d2c28;
--color-fd-primary: #f36117;
--color-fd-secondary: #1f1e1b;
--color-fd-accent: #2d2c28;
--color-fd-ring: #55534c;
color-scheme: dark;
}
}
@layer base {
*,
::before,
::after {
border-color: var(--color-fd-border);
}
body {
background-color: var(--color-fd-background);
color: var(--color-fd-foreground);
}
}
/* chat answers: typography plugin, tinted to the palette */
.prose {
--tw-prose-body: var(--color-fd-foreground);
--tw-prose-headings: var(--color-fd-foreground);
--tw-prose-links: var(--color-fd-foreground);
--tw-prose-bold: var(--color-fd-foreground);
--tw-prose-code: var(--color-fd-foreground);
--tw-prose-bullets: var(--color-fd-muted-foreground);
--tw-prose-counters: var(--color-fd-muted-foreground);
--tw-prose-pre-bg: var(--color-fd-background);
--tw-prose-pre-code: var(--color-fd-foreground);
--tw-prose-th-borders: var(--color-fd-border);
--tw-prose-td-borders: var(--color-fd-border);
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Link from 'next/link';
import './globals.css';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export const metadata: Metadata = {
title: 'Docs analytics',
robots: { index: false, follow: false },
};
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.variable}>
<body className="min-h-screen font-sans antialiased">
<header className="border-b">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<Link href="/" className="font-medium">
Pangolin Docs · Analytics
</Link>
<a href={docsUrl} className="text-sm text-fd-muted-foreground hover:text-fd-foreground">
View docs ↗
</a>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6 md:py-8">{children}</main>
</body>
</html>
);
}
+435
View File
@@ -0,0 +1,435 @@
import Link from 'next/link';
import { connection } from 'next/server';
import { cn } from '@/lib/cn';
import {
getDownvotedResponses,
getQuestionActivity,
getSummary,
getThreads,
getTopPages,
getTopSearches,
type Range,
} from '@/lib/queries';
import {
formatDateTime,
formatDay,
oldestDay,
param,
presets,
resolveRange,
withParams,
type SearchParams,
} from '@/lib/range';
import { ActivityChart } from '@/components/activity-chart';
import { Card, CardTitle, Empty, percent, Stat, Votes } from '@/components/ui';
const PAGE_SIZE = 25;
const inputClass =
'rounded-md border bg-fd-background px-2 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring';
const buttonClass =
'rounded-md border bg-fd-secondary px-3 py-1.5 text-sm font-medium hover:bg-fd-accent';
/** answer snippets: drop the Markdown syntax that would show up as literal characters */
function plain(markdown: string) {
return markdown
.replace(/```[\s\S]*?```/g, ' [code] ')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[*`#>]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
export default async function AnalyticsPage(props: PageProps<'/'>) {
await connection();
const params: SearchParams = await props.searchParams;
const range = resolveRange(params);
const q = param(params, 'q');
const feedbackParam = param(params, 'feedback');
const feedback = feedbackParam === 'up' || feedbackParam === 'down' ? feedbackParam : undefined;
const offset = Math.max(0, Number(param(params, 'offset')) || 0);
let data;
try {
data = await Promise.all([
getSummary(range),
getQuestionActivity(range),
getTopPages(range, 'up'),
getTopPages(range, 'down'),
getDownvotedResponses(range),
getThreads({ ...range, q, feedback, offset, limit: PAGE_SIZE }),
getTopSearches(range),
getTopSearches(range, { empty: true }),
]);
} catch (e) {
console.error('[analytics] dashboard', e);
return (
<Card>
<CardTitle>Analytics database unavailable</CardTitle>
<p className="text-sm text-fd-muted-foreground">
Check <code>DATABASE_URL</code> and the server logs. Error:{' '}
<code>{e instanceof Error ? e.message : String(e)}</code>
</p>
</Card>
);
}
const [summary, activity, topUp, topDown, downvoted, threads, topSearches, emptySearches] = data;
const pageVotes = summary.pages.up + summary.pages.down;
const responseVotes = summary.responses.up + summary.responses.down;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h1 className="text-2xl font-semibold">Docs analytics</h1>
<p className="text-sm text-fd-muted-foreground">{range.label}</p>
</div>
<RangeFilter params={params} range={range} activePreset={range.preset} />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Stat
label="Pages rated helpful"
value={percent(summary.pages.up, pageVotes)}
detail={<Votes up={summary.pages.up} down={summary.pages.down} />}
/>
<Stat label="AI chats" value={summary.threads.toLocaleString()} detail="conversations started" />
<Stat
label="Questions asked"
value={summary.questions.toLocaleString()}
detail={summary.threads > 0 ? `${(summary.questions / summary.threads).toFixed(1)} per chat` : undefined}
/>
<Stat
label="Searches"
value={summary.searches.total.toLocaleString()}
detail={
summary.searches.total > 0
? `${percent(summary.searches.empty, summary.searches.total)} found nothing`
: undefined
}
/>
<Stat
label="AI answers rated good"
value={percent(summary.responses.up, responseVotes)}
detail={<Votes up={summary.responses.up} down={summary.responses.down} />}
/>
</div>
<Card>
<CardTitle hint="per day, UTC">
AI questions asked
</CardTitle>
<ActivityChart points={activity} />
</Card>
<div className="grid gap-3 lg:grid-cols-2">
<PageTable title="Most upvoted pages" rows={topUp} empty="No upvotes in this range." />
<PageTable title="Most downvoted pages" rows={topDown} empty="No downvotes in this range." />
</div>
<div className="grid gap-3 lg:grid-cols-2">
<SearchTable
title="Top searches"
hint="opened = picked a result"
rows={topSearches}
empty="No searches in this range."
/>
<SearchTable
title="Searches with no results"
hint="gaps in the docs"
rows={emptySearches}
empty="Every search found something."
noResults
/>
</div>
<Card>
<CardTitle hint="most recent first">Downvoted AI answers</CardTitle>
{downvoted.length === 0 ? (
<Empty>No downvoted answers in this range.</Empty>
) : (
<ul className="divide-y">
{downvoted.map((r) => (
<li key={r.messageId} className="py-3 first:pt-0 last:pb-0">
<Link
href={`/chats/${r.threadId}#${r.messageId}`}
className="group block min-w-0"
>
<p className="text-sm font-medium group-hover:underline line-clamp-2">{r.question}</p>
<p className="mt-1 text-sm text-fd-muted-foreground line-clamp-2">{plain(r.answer)}</p>
<p className="mt-1 text-xs text-fd-muted-foreground">{formatDateTime(r.votedAt)}</p>
</Link>
</li>
))}
</ul>
)}
</Card>
<Card id="chats">
<CardTitle hint="newest first">AI chats</CardTitle>
<form className="mb-3 flex flex-wrap items-center gap-2" action="/#chats">
{/* keep the time range when searching */}
{(['range', 'from', 'to'] as const).map((key) => {
const value = param(params, key);
return value ? <input key={key} type="hidden" name={key} value={value} /> : null;
})}
<input
name="q"
defaultValue={q}
placeholder="Search questions…"
aria-label="Search questions"
className={cn(inputClass, 'min-w-0 flex-1 basis-48')}
/>
<select name="feedback" defaultValue={feedback ?? ''} aria-label="Feedback" className={inputClass}>
<option value="">Any feedback</option>
<option value="down">Has 👎</option>
<option value="up">Has 👍</option>
</select>
<button type="submit" className={buttonClass}>
Filter
</button>
{(q || feedback) && (
<Link
href={`/${withParams(params, { q: undefined, feedback: undefined, offset: undefined })}#chats`}
className="text-sm text-fd-muted-foreground hover:text-fd-foreground"
>
Reset
</Link>
)}
</form>
{threads.threads.length === 0 ? (
<Empty>No chats match.</Empty>
) : (
<div className="-mx-4 overflow-x-auto px-4">
<table className="w-full min-w-[40rem] text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">First question</th>
<th className="pb-2 text-start font-normal">Started on</th>
<th className="pb-2 text-end font-normal">Questions</th>
<th className="pb-2 text-end font-normal">Feedback</th>
<th className="pb-2 text-end font-normal">When</th>
</tr>
</thead>
<tbody>
{threads.threads.map((t) => (
<tr key={t.id} className="border-t align-top">
<td className="py-2 pe-4">
<Link href={`/chats/${t.id}`} className="line-clamp-2 hover:underline">
{t.firstQuestion}
</Link>
</td>
<td className="max-w-[14rem] py-2 pe-4 text-fd-muted-foreground">
<span className="block truncate" title={t.page ?? undefined}>
{t.page ?? '—'}
</span>
</td>
<td className="py-2 text-end tabular-nums">{t.questions}</td>
<td className="py-2 ps-4 text-end">
{t.up + t.down > 0 ? <Votes up={t.up} down={t.down} /> : <span className="text-fd-muted-foreground">—</span>}
</td>
<td className="py-2 ps-4 text-end whitespace-nowrap text-fd-muted-foreground">
{formatDateTime(t.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{(offset > 0 || threads.hasMore) && (
<nav className="mt-3 flex items-center justify-between text-sm">
{offset > 0 ? (
<Link
href={`/${withParams(params, { offset: offset - PAGE_SIZE > 0 ? String(offset - PAGE_SIZE) : undefined })}#chats`}
className={buttonClass}
>
← Newer
</Link>
) : (
<span />
)}
<span className="text-xs text-fd-muted-foreground">
{offset + 1}–{offset + threads.threads.length}
</span>
{threads.hasMore ? (
<Link
href={`/${withParams(params, { offset: String(offset + PAGE_SIZE) })}#chats`}
className={buttonClass}
>
Older →
</Link>
) : (
<span />
)}
</nav>
)}
</Card>
</div>
);
}
function RangeFilter({
params,
range,
activePreset,
}: {
params: SearchParams;
range: Range;
activePreset: string | null;
}) {
const reset = { from: undefined, to: undefined, offset: undefined };
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex flex-wrap gap-1 rounded-lg border bg-fd-secondary p-1" role="group" aria-label="Time range">
{presets.map((p) => (
<Link
key={p.id}
href={`/${withParams(params, { ...reset, range: p.id === '30d' ? undefined : p.id })}`}
aria-current={activePreset === p.id ? 'true' : undefined}
className={cn(
'rounded-md px-2.5 py-1 text-sm text-fd-muted-foreground hover:text-fd-foreground',
activePreset === p.id && 'bg-fd-background text-fd-foreground shadow-sm',
)}
>
{p.label}
</Link>
))}
</div>
<form className="flex flex-wrap items-center gap-2" action="/">
{(['q', 'feedback'] as const).map((key) => {
const value = param(params, key);
return value ? <input key={key} type="hidden" name={key} value={value} /> : null;
})}
<input
type="date"
name="from"
aria-label="From"
min={oldestDay()}
max={formatDay(Date.now())}
defaultValue={activePreset ? undefined : formatDay(range.from)}
className={inputClass}
/>
<span className="text-sm text-fd-muted-foreground">to</span>
<input
type="date"
name="to"
aria-label="To"
min={oldestDay()}
max={formatDay(Date.now())}
defaultValue={activePreset ? undefined : formatDay(range.to - 1)}
className={inputClass}
/>
<button type="submit" className={buttonClass}>
Apply
</button>
</form>
</div>
);
}
function PageTable({
title,
rows,
empty,
}: {
title: string;
rows: { page: string; up: number; down: number }[];
empty: string;
}) {
return (
<Card>
<CardTitle>{title}</CardTitle>
{rows.length === 0 ? (
<Empty>{empty}</Empty>
) : (
<table className="w-full table-fixed text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">Page</th>
<th className="w-28 pb-2 text-end font-normal">Votes</th>
<th className="w-16 pb-2 text-end font-normal">Helpful</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.page} className="border-t">
<td className="py-2 pe-3">
<a
href={`${docsUrl}${r.page}`}
target="_blank"
rel="noreferrer"
className="block truncate hover:underline"
title={r.page}
>
{r.page}
</a>
</td>
<td className="py-2 text-end">
<Votes up={r.up} down={r.down} />
</td>
<td className="py-2 text-end tabular-nums">{percent(r.up, r.up + r.down)}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
function SearchTable({
title,
hint,
rows,
empty,
noResults = false,
}: {
title: string;
hint: string;
rows: { query: string; count: number; clicks: number; results: number; lastSearched: number }[];
empty: string;
noResults?: boolean;
}) {
return (
<Card>
<CardTitle hint={hint}>{title}</CardTitle>
{rows.length === 0 ? (
<Empty>{empty}</Empty>
) : (
<table className="w-full table-fixed text-sm">
<thead>
<tr className="text-xs text-fd-muted-foreground">
<th className="pb-2 text-start font-normal">Query</th>
<th className="w-20 pb-2 text-end font-normal">Searches</th>
<th className="w-24 pb-2 text-end font-normal">{noResults ? 'Last' : 'Opened'}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.query} className="border-t">
<td className="py-2 pe-3">
<span className="block truncate" title={r.query}>
{r.query}
</span>
{!noResults && r.results === 0 && (
<span className="block text-xs text-fd-muted-foreground">no results</span>
)}
</td>
<td className="py-2 text-end tabular-nums">{r.count}</td>
<td className="py-2 text-end tabular-nums text-fd-muted-foreground">
{noResults ? formatDay(r.lastSearched) : percent(r.clicks, r.count)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
@@ -0,0 +1,82 @@
import { cn } from '@/lib/cn';
import { formatDay } from '@/lib/range';
import { Empty } from './ui';
/**
* Single-series bar chart with a hover/focus tooltip per bar (pure CSS, no client JS)
* and a table view for screen readers and exact numbers.
*/
export function ActivityChart({ points }: { points: { start: number; count: number }[] }) {
if (points.length === 0) return <Empty>No questions in this range.</Empty>;
const max = Math.max(1, ...points.map((p) => p.count));
// first, middle and last bucket; with only 1–2 buckets these overlap
const ticks = [...new Set([points[0], points[Math.floor((points.length - 1) / 2)], points.at(-1)!])];
return (
<div>
<div className="relative">
{/* recessive gridlines: max and half */}
<div className="pointer-events-none absolute inset-x-0 top-0 border-t border-dashed border-fd-border" />
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-t border-dashed border-fd-border" />
<span className="absolute -top-2 end-0 bg-fd-card ps-1 text-[11px] text-fd-muted-foreground tabular-nums">
{max}
</span>
<div className="flex h-40 items-end justify-center gap-[2px] border-b border-fd-border pe-6" aria-hidden>
{points.map((p, i) => (
<div
key={p.start}
tabIndex={0}
className="group relative flex h-full min-w-0 max-w-12 flex-1 items-end outline-none"
>
<div
className="w-full rounded-t-[4px] bg-fd-primary/80 transition-colors group-hover:bg-fd-primary group-focus-visible:bg-fd-primary"
style={{ height: p.count === 0 ? 0 : `max(2px, ${(p.count / max) * 100}%)` }}
/>
{/* tooltips at the edges anchor inward so they never overflow the page */}
<div
className={cn(
'pointer-events-none absolute bottom-full z-10 mb-1 hidden whitespace-nowrap rounded-md border bg-fd-popover px-2 py-1 text-xs text-fd-popover-foreground shadow-md group-hover:block group-focus-visible:block',
i < points.length / 4
? 'left-0'
: i >= (points.length * 3) / 4
? 'right-0'
: 'left-1/2 -translate-x-1/2',
)}
>
<span className="text-fd-muted-foreground">{formatDay(p.start)}</span>{' '}
<strong className="tabular-nums">{p.count}</strong>
</div>
</div>
))}
</div>
</div>
<div className="mt-1 flex justify-between pe-6 text-[11px] text-fd-muted-foreground tabular-nums">
{ticks.map((p) => (
<span key={p.start}>{formatDay(p.start)}</span>
))}
</div>
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-fd-muted-foreground">Show as table</summary>
<table className="mt-2 w-full max-w-xs tabular-nums">
<thead>
<tr className="text-start text-fd-muted-foreground">
<th className="py-1 text-start font-normal">Day</th>
<th className="py-1 text-end font-normal">Questions</th>
</tr>
</thead>
<tbody>
{points.map((p) => (
<tr key={p.start} className="border-t">
<td className="py-1">{formatDay(p.start)}</td>
<td className="py-1 text-end">{p.count}</td>
</tr>
))}
</tbody>
</table>
</details>
</div>
);
}
@@ -0,0 +1,20 @@
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
const docsUrl = process.env.DOCS_SITE_URL ?? 'https://docs.pangolin.net';
/** assistant answers are Markdown; relative links point at the docs site, in a new tab */
export function MessageMarkdown({ text }: { text: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node: _, href, ...props }) => (
<a {...props} href={href?.startsWith('/') ? `${docsUrl}${href}` : href} target="_blank" rel="noreferrer" />
),
}}
>
{text}
</Markdown>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/cn';
export function Card({ className, ...props }: ComponentProps<'section'>) {
return <section className={cn('rounded-xl border bg-fd-card p-4 min-w-0', className)} {...props} />;
}
export function CardTitle({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
return (
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 className="text-sm font-medium">{children}</h2>
{hint && <p className="text-xs text-fd-muted-foreground">{hint}</p>}
</div>
);
}
export function Stat({ label, value, detail }: { label: string; value: ReactNode; detail?: ReactNode }) {
return (
<Card>
<p className="text-xs text-fd-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
{detail && <p className="mt-1 text-xs text-fd-muted-foreground tabular-nums">{detail}</p>}
</Card>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="py-6 text-center text-sm text-fd-muted-foreground">{children}</p>;
}
/** 👍 / 👎 counts as text + icon, never color alone */
export function Votes({ up, down }: { up: number; down: number }) {
return (
<span className="inline-flex gap-3 tabular-nums whitespace-nowrap">
<span title="Thumbs up">👍 {up}</span>
<span title="Thumbs down">👎 {down}</span>
</span>
);
}
export function percent(part: number, total: number) {
return total === 0 ? '—' : `${Math.round((part / total) * 100)}%`;
}
+22
View File
@@ -0,0 +1,22 @@
import 'server-only';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
export type Db = NodePgDatabase<typeof schema>;
const globalForDb = globalThis as unknown as { __analyticsDb?: Db };
/** direct connection to the Fossorial API's Postgres (`DATABASE_URL`), read-only use */
export function getDb(): Db {
if (globalForDb.__analyticsDb) return globalForDb.__analyticsDb;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is not set (see .env.example)');
// one pool across dev hot reloads
globalForDb.__analyticsDb = drizzle(new Pool({ connectionString, max: 5 }), { schema });
return globalForDb.__analyticsDb;
}
export * from './schema';
+79
View File
@@ -0,0 +1,79 @@
/**
* Read-only copy of the docs analytics tables from the Fossorial API
* (api repo: src/services/db/schema.ts, `docs*` tables). The API owns these tables and
* their migrations; keep this file in sync when they change. This app never writes.
*/
import { index, integer, jsonb, pgTable, primaryKey, text, timestamp, varchar } from 'drizzle-orm/pg-core';
export const docsPageVoteTable = pgTable(
'docsPageVote',
{
page: text('page').notNull(),
visitorId: varchar('visitor_id', { length: 64 }).notNull(),
vote: integer('vote').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.page, t.visitorId] }), index('docsPageVote_updated_at_idx').on(t.updatedAt)],
);
export const docsChatThreadTable = pgTable(
'docsChatThread',
{
id: varchar('id', { length: 64 }).primaryKey(),
visitorId: varchar('visitor_id', { length: 64 }),
page: text('page'),
firstQuestion: text('first_question').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsChatThread_created_at_idx').on(t.createdAt)],
);
export const docsChatMessageTable = pgTable(
'docsChatMessage',
{
id: varchar('id', { length: 64 }).primaryKey(),
threadId: varchar('thread_id', { length: 64 })
.notNull()
.references(() => docsChatThreadTable.id, { onDelete: 'cascade' }),
role: varchar('role', { length: 16 }).$type<'user' | 'assistant'>().notNull(),
content: text('content').notNull(),
tools: jsonb('tools').$type<{ name: string; input?: unknown }[]>(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('docsChatMessage_thread_idx').on(t.threadId, t.createdAt),
index('docsChatMessage_created_at_idx').on(t.createdAt),
],
);
export const docsMessageVoteTable = pgTable(
'docsMessageVote',
{
messageId: varchar('message_id', { length: 64 })
.primaryKey()
.references(() => docsChatMessageTable.id, { onDelete: 'cascade' }),
threadId: varchar('thread_id', { length: 64 }).notNull(),
visitorId: varchar('visitor_id', { length: 64 }),
vote: integer('vote').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsMessageVote_updated_at_idx').on(t.updatedAt)],
);
export const docsSearchQueryTable = pgTable(
'docsSearchQuery',
{
id: varchar('id', { length: 64 }).primaryKey(),
visitorId: varchar('visitor_id', { length: 64 }),
query: varchar('query', { length: 256 }).notNull(),
results: integer('results').notNull(),
clickedUrl: text('clicked_url'),
page: text('page'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('docsSearchQuery_created_at_idx').on(t.createdAt)],
);
+3
View File
@@ -0,0 +1,3 @@
export function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
+244
View File
@@ -0,0 +1,244 @@
import 'server-only';
import { and, asc, count, desc, eq, exists, gte, ilike, lt, max, min, sql, type AnyColumn } from 'drizzle-orm';
import {
docsChatMessageTable as message,
docsChatThreadTable as thread,
docsMessageVoteTable as messageVote,
docsPageVoteTable as pageVote,
docsSearchQueryTable as search,
getDb,
} from '@/db';
/** epoch ms; `to` is exclusive */
export interface Range {
from: number;
to: number;
}
const DAY = 86_400_000;
function inRange(column: AnyColumn, { from, to }: Range) {
return and(gte(column, new Date(from)), lt(column, new Date(to)));
}
const upvotes = (vote: AnyColumn) => sql<number>`coalesce(sum(case when ${vote} = 1 then 1 else 0 end), 0)::int`;
const downvotes = (vote: AnyColumn) => sql<number>`coalesce(sum(case when ${vote} = -1 then 1 else 0 end), 0)::int`;
export async function getSummary(range: Range) {
const db = getDb();
const [[pages], [responses], [threads], [questions], [searches]] = await Promise.all([
db
.select({ up: upvotes(pageVote.vote), down: downvotes(pageVote.vote) })
.from(pageVote)
.where(inRange(pageVote.updatedAt, range)),
db
.select({ up: upvotes(messageVote.vote), down: downvotes(messageVote.vote) })
.from(messageVote)
.where(inRange(messageVote.updatedAt, range)),
db.select({ n: count() }).from(thread).where(inRange(thread.createdAt, range)),
db
.select({ n: count() })
.from(message)
.where(and(eq(message.role, 'user'), inRange(message.createdAt, range))),
db
.select({
n: count(),
empty: sql<number>`coalesce(sum(case when ${search.results} = 0 then 1 else 0 end), 0)::int`,
})
.from(search)
.where(inRange(search.createdAt, range)),
]);
return {
pages,
responses,
threads: threads.n,
questions: questions.n,
searches: { total: searches.n, empty: searches.empty },
};
}
/** start of the UTC day */
function dayStart(t: number) {
return t - (((t % DAY) + DAY) % DAY);
}
/** questions asked per UTC day, with empty days filled in */
export async function getQuestionActivity(range: Range) {
const db = getDb();
const userInRange = and(eq(message.role, 'user'), inRange(message.createdAt, range));
const [bounds] = await db.select({ first: min(message.createdAt) }).from(message).where(userInRange);
if (!bounds?.first) return [];
// epoch ms of the day; `at time zone 'UTC'` makes day boundaries UTC midnight
const dayOf = sql<number>`(extract(epoch from date_trunc('day', ${message.createdAt} at time zone 'UTC')) * 1000)::float8`;
const rows = await db.select({ day: dayOf, n: count() }).from(message).where(userInRange).groupBy(dayOf);
const counts = new Map(rows.map((r) => [Number(r.day), r.n]));
const points: { start: number; count: number }[] = [];
for (let d = dayStart(range.from); d <= dayStart(Math.min(range.to, Date.now())); d += DAY) {
points.push({ start: d, count: counts.get(d) ?? 0 });
}
return points;
}
export async function getTopPages(range: Range, by: 'up' | 'down', limit = 15) {
const up = upvotes(pageVote.vote);
const down = downvotes(pageVote.vote);
return getDb()
.select({ page: pageVote.page, up, down })
.from(pageVote)
.where(inRange(pageVote.updatedAt, range))
.groupBy(pageVote.page)
.having(sql`${by === 'up' ? up : down} > 0`)
.orderBy(desc(by === 'up' ? up : down), asc(by === 'up' ? down : up), asc(pageVote.page))
.limit(limit);
}
/** `ilike` pattern matching `q` literally (escapes `%`, `_` and `\`) */
function containsPattern(q: string) {
return `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
}
export interface ThreadFilter extends Range {
q?: string;
feedback?: 'up' | 'down';
offset?: number;
limit?: number;
}
/** the outer thread's id, table-qualified for use inside correlated subqueries */
const threadId = sql`${sql.identifier('docsChatThread')}.${sql.identifier('id')}`;
export async function getThreads({ q, feedback, offset = 0, limit = 25, ...range }: ThreadFilter) {
const db = getDb();
const filters = [inRange(thread.createdAt, range)];
if (q) {
filters.push(
exists(
db
.select({ one: sql`1` })
.from(message)
.where(
and(eq(message.threadId, thread.id), eq(message.role, 'user'), ilike(message.content, containsPattern(q))),
),
),
);
}
if (feedback) {
filters.push(
exists(
db
.select({ one: sql`1` })
.from(messageVote)
.where(and(eq(messageVote.threadId, thread.id), eq(messageVote.vote, feedback === 'up' ? 1 : -1))),
),
);
}
// fetch one extra row to know whether there is a next page
const rows = await db
.select({
id: thread.id,
firstQuestion: thread.firstQuestion,
page: thread.page,
createdAt: thread.createdAt,
// Drizzle leaves columns unqualified in a single-table select, so these correlated
// subqueries alias the inner table and name the outer one explicitly
questions: sql<number>`(select count(*)::int from ${message} m where m.thread_id = ${threadId} and m.role = 'user')`,
up: sql<number>`(select count(*)::int from ${messageVote} v where v.thread_id = ${threadId} and v.vote = 1)`,
down: sql<number>`(select count(*)::int from ${messageVote} v where v.thread_id = ${threadId} and v.vote = -1)`,
})
.from(thread)
.where(and(...filters))
.orderBy(desc(thread.createdAt), asc(thread.id))
.limit(limit + 1)
.offset(offset);
return {
hasMore: rows.length > limit,
threads: rows.slice(0, limit).map((r) => ({ ...r, createdAt: r.createdAt.getTime() })),
};
}
/** most recent thumbs-down answers with the question they answered */
export async function getDownvotedResponses(range: Range, limit = 10) {
const rows = await getDb()
.select({
threadId: messageVote.threadId,
messageId: messageVote.messageId,
votedAt: messageVote.updatedAt,
answer: message.content,
// the latest question asked before this answer (`u` is the inner copy of the table)
question: sql<string | null>`(select u.content from ${message} u where u.thread_id = ${message.threadId} and u.role = 'user' and u.created_at <= ${message.createdAt} order by u.created_at desc limit 1)`,
})
.from(messageVote)
.innerJoin(message, eq(message.id, messageVote.messageId))
.where(and(eq(messageVote.vote, -1), inRange(messageVote.updatedAt, range)))
.orderBy(desc(messageVote.updatedAt))
.limit(limit);
return rows.map((r) => ({ ...r, question: r.question ?? '', votedAt: r.votedAt.getTime() }));
}
export async function getThread(id: string) {
const db = getDb();
const [row] = await db.select().from(thread).where(eq(thread.id, id));
if (!row) return null;
const messages = await db
.select({
id: message.id,
role: message.role,
content: message.content,
tools: message.tools,
createdAt: message.createdAt,
vote: messageVote.vote,
})
.from(message)
.leftJoin(messageVote, eq(messageVote.messageId, message.id))
.where(eq(message.threadId, id))
// same-instant ties: user before assistant
.orderBy(asc(message.createdAt), desc(message.role));
return {
id: row.id,
page: row.page,
visitorId: row.visitorId,
createdAt: row.createdAt.getTime(),
messages: messages.map((m) => ({
...m,
tools: m.tools ?? [],
createdAt: m.createdAt.getTime(),
vote: m.vote ?? 0,
})),
};
}
/** searches grouped case-insensitively; `empty` limits them to ones that found nothing */
export async function getTopSearches(range: Range, { empty = false, limit = 15 } = {}) {
const filters = [inRange(search.createdAt, range)];
if (empty) filters.push(eq(search.results, 0));
const rows = await getDb()
.select({
query: max(search.query),
count: count(),
clicks: sql<number>`coalesce(sum(case when ${search.clickedUrl} is not null then 1 else 0 end), 0)::int`,
results: max(search.results),
lastSearched: max(search.createdAt),
})
.from(search)
.where(and(...filters))
.groupBy(sql`lower(${search.query})`)
.orderBy(desc(count()), desc(max(search.createdAt)))
.limit(limit);
return rows.map((r) => ({
query: r.query ?? '',
count: r.count,
clicks: r.clicks,
results: r.results ?? 0,
lastSearched: r.lastSearched?.getTime() ?? 0,
}));
}
+91
View File
@@ -0,0 +1,91 @@
const DAY = 86_400_000;
/**
* The Fossorial API deletes docs analytics older than this (api repo,
* src/controllers/docsAnalytics/retention.ts), so no range reaches further back.
*/
export const RETENTION_DAYS = 90;
export const presets = [
{ id: '24h', label: '24 hours', ms: DAY },
{ id: '7d', label: '7 days', ms: 7 * DAY },
{ id: '30d', label: '30 days', ms: 30 * DAY },
{ id: '90d', label: '90 days', ms: RETENTION_DAYS * DAY },
] as const;
/** first day that can still have data, as `YYYY-MM-DD` (for date inputs) */
export function oldestDay(now = Date.now()) {
return formatDay(now - RETENTION_DAYS * DAY);
}
export type SearchParams = Record<string, string | string[] | undefined>;
export function param(params: SearchParams, key: string) {
const value = params[key];
return (Array.isArray(value) ? value[0] : value)?.trim() || undefined;
}
/** `YYYY-MM-DD` as UTC midnight */
function parseDay(value: string | undefined) {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const t = Date.parse(`${value}T00:00:00Z`);
return Number.isNaN(t) ? undefined : t;
}
export function formatDay(t: number) {
return new Date(t).toISOString().slice(0, 10);
}
/**
* `?range=7d` picks a preset (default 30 days); `?from=2026-01-01&to=2026-01-31` is a
* custom range in UTC with both days included, clamped to the retention window.
*/
export function resolveRange(params: SearchParams, now = Date.now()) {
const from = parseDay(param(params, 'from'));
const to = parseDay(param(params, 'to'));
if (from !== undefined || to !== undefined) {
const floor = now - RETENTION_DAYS * DAY;
const end = to !== undefined ? to + DAY : now + 1;
const start = Math.max(Math.min(from ?? floor, end), floor);
return {
preset: null,
from: start,
to: Math.max(start, end),
label: `${formatDay(start)} to ${to !== undefined ? formatDay(to) : 'now'}`,
};
}
const preset = presets.find((p) => p.id === param(params, 'range')) ?? presets[2];
return {
preset: preset.id,
from: now - preset.ms,
to: now + 1,
label: `Last ${preset.label}`,
};
}
/** a query string with some keys replaced; `undefined` drops a key */
export function withParams(params: SearchParams, changes: Record<string, string | undefined>) {
const next = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const v = Array.isArray(value) ? value[0] : value;
if (v && !(key in changes)) next.set(key, v);
}
for (const [key, value] of Object.entries(changes)) if (value) next.set(key, value);
const qs = next.toString();
return qs ? `?${qs}` : '?';
}
const dateTime = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC',
});
export function formatDateTime(t: number) {
return `${dateTime.format(t)} UTC`;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2
View File
@@ -16,6 +16,7 @@ import { AskAIAboutPage } from '@/components/ai/ask-page';
import { SiteFooter } from '@/components/site-footer';
import { AskBar } from '@/components/ai/ask-bar';
import { pageJsonLd } from '@/lib/json-ld';
import { PageFeedback } from '@/components/page-feedback';
export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
@@ -36,6 +37,7 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
footer={{
children: (
<>
<PageFeedback page={page.url} />
<SiteFooter />
<AskBar />
</>
+33
View File
@@ -0,0 +1,33 @@
import { parseId, recordSearch } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
function path(value: unknown, max: number) {
return typeof value === 'string' && value.startsWith('/') ? value.slice(0, max) : null;
}
/** `{ id, query, results, clickedUrl?, page?, visitorId }` from the search dialog */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const id = parseId(body.id);
const query = typeof body.query === 'string' ? body.query.replace(/\s+/g, ' ').trim().slice(0, 256) : '';
const results = Number(body.results);
if (!id || !query || !Number.isInteger(results) || results < 0) {
return json(400, { error: 'Invalid search event.' });
}
try {
await recordSearch({
id,
visitorId: parseId(body.visitorId),
query,
results: Math.min(results, 1000),
clickedUrl: path(body.clickedUrl, 512),
page: path(body.page, 2048),
});
} catch (e) {
return storageError('search', e);
}
return json(200, { ok: true });
}
+29 -1
View File
@@ -1,6 +1,8 @@
import { after } from 'next/server';
import {
convertToModelMessages,
createUIMessageStreamResponse,
generateId,
stepCountIs,
streamText,
toUIMessageStream,
@@ -11,6 +13,7 @@ import { AIConfigError, getModel } from '@/lib/ai/model';
import { getSystemPrompt } from '@/lib/ai/prompt';
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
import { tools } from '@/lib/ai/tools';
import { parseId, recordAssistantMessage, recordUserMessage } from '@/lib/analytics/api';
export const maxDuration = 120;
@@ -37,13 +40,31 @@ export async function POST(req: Request) {
throw e;
}
const body = (await req.json().catch(() => null)) as { messages?: ChatUIMessage[] } | null;
const body = (await req.json().catch(() => null)) as {
messages?: ChatUIMessage[];
threadId?: unknown;
visitorId?: unknown;
} | null;
const messages = (body?.messages ?? []).slice(-MAX_MESSAGES);
const tooLong = messages.some((m) =>
m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS),
);
if (messages.length === 0 || tooLong) return json(400, 'Invalid or too long message.');
// analytics: store the question now and the answer when the stream ends. Failures are
// logged and never affect the chat; `after` keeps the function alive for the writes.
const threadId = parseId(body?.threadId);
const visitorId = parseId(body?.visitorId);
const question = messages.at(-1);
const logError = (e: unknown) => console.error('[analytics] chat', e);
let saved: Promise<void> = Promise.resolve();
if (threadId && question?.role === 'user') {
saved = recordUserMessage(threadId, visitorId, question).catch(logError);
after(async () => {
await saved;
});
}
const instructions: SystemModelMessage = {
role: 'system',
content: getSystemPrompt(),
@@ -74,6 +95,13 @@ export async function POST(req: Request) {
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
// gives the response a stable id so the client can vote on it
originalMessages: messages,
generateMessageId: generateId,
onEnd({ responseMessage }) {
if (!threadId) return;
saved = saved.then(() => recordAssistantMessage(threadId, responseMessage)).catch(logError);
},
onError: () => 'The assistant hit an error. Please try again.',
}),
});
+20
View File
@@ -0,0 +1,20 @@
import { parseId, parseVote, recordMessageVote } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
/** `{ messageId, vote: 1 | -1 | 0, visitorId }` for an assistant response */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const messageId = parseId(body.messageId);
const vote = parseVote(body.vote);
if (!messageId || vote === null) return json(400, { error: 'Invalid feedback.' });
try {
const found = await recordMessageVote(messageId, parseId(body.visitorId), vote);
if (!found) return json(404, { error: 'Unknown message.' });
} catch (e) {
return storageError('message vote', e);
}
return json(200, { ok: true });
}
+24
View File
@@ -0,0 +1,24 @@
import { source } from '@/lib/source';
import { parseId, parseVote, recordPageVote } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
/** `{ page: '/some/path', vote: 1 | -1 | 0, visitorId }`; `0` removes the vote */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const visitorId = parseId(body.visitorId);
const vote = parseVote(body.vote);
const page = typeof body.page === 'string' ? body.page : '';
const slugs = page.split('/').filter(Boolean);
if (!visitorId || vote === null || !page.startsWith('/') || !source.getPage(slugs)) {
return json(400, { error: 'Invalid feedback.' });
}
try {
await recordPageVote(source.getPage(slugs)!.url, visitorId, vote);
} catch (e) {
return storageError('page vote', e);
}
return json(200, { ok: true });
}
+62 -72
View File
@@ -74,23 +74,6 @@ body {
color: var(--color-fd-foreground);
}
/* pangolin.net button typeface */
@font-face {
font-family: 'Die Grotesk B';
src: url('/fonts/die-grotesk-b-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Die Grotesk B';
src: url('/fonts/die-grotesk-b-medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/*
* Hairlines like pangolin.net (0.5px, #bbb). Unlayered, so these win over the 1px
* Tailwind border utilities used by Fumadocs; 2px accents (active tab) are untouched.
@@ -184,6 +167,18 @@ body {
border-inline-end: 0.5px solid var(--color-fd-border);
}
/* AI chat panel: same 0.5px hairline as the sidebar */
.pg-ai-panel {
border: 0.5px solid var(--color-fd-border);
}
@media (min-width: 1024px) {
.pg-ai-panel {
border-width: 0;
border-inline-start: 0.5px solid var(--color-fd-border);
}
}
/* separators = Mintlify top-level group headings */
#nd-sidebar p[data-separator],
#nd-sidebar [data-separator] {
@@ -255,44 +250,46 @@ body {
color: #faf9f2;
}
/* header buttons: pangolin.net `btn` / `btnSecondary` + `btnSm` */
/* header CTAs: same height, radius, and hairline as the search bar and Ask AI */
.pg-btn {
display: inline-block;
border-radius: 9999px;
border: 0;
background-color: #202020;
color: #faf9f2;
font-family: 'Die Grotesk B', var(--font-sans);
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0 0.85rem;
border: 0.5px solid transparent;
border-radius: 0.75rem;
background-color: var(--color-fd-primary);
color: var(--color-fd-primary-foreground);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
padding: 0.75rem 1.25rem;
line-height: 1;
white-space: nowrap;
text-decoration: none;
transition: transform 300ms;
transition:
background-color 150ms,
color 150ms,
border-color 150ms;
}
.pg-btn:hover {
transform: scale(1.025);
background-color: color-mix(in srgb, var(--color-fd-primary) 80%, transparent);
}
.pg-btn:focus-visible {
outline: 2px solid var(--color-fd-ring);
outline-offset: 2px;
}
.pg-btn-secondary {
border: 1px solid #bbbbbb;
background-color: #faf9f2;
color: #202020;
/* 1px border: keep the same outer size as the primary button */
padding: calc(0.75rem - 1px) calc(1.25rem - 1px);
border-color: var(--color-fd-border);
background-color: var(--color-fd-background);
color: var(--color-fd-foreground);
}
.dark .pg-btn {
background-color: #ecebe6;
color: #161614;
}
.dark .pg-btn-secondary {
border-color: #3a3934;
background-color: transparent;
color: #ecebe6;
.pg-btn-secondary:hover {
background-color: var(--color-fd-accent);
color: var(--color-fd-accent-foreground);
}
/* "Ask AI" button to the right of the header search (Mintlify style) */
@@ -500,53 +497,44 @@ figure.shiki .line.highlighted {
font-size: 0.875rem;
}
/* ---------- callouts: beige with a coloured bar on the left ---------- */
/* ---------- callouts: sand wash tinted by type, hairline in the same colour ---------- */
.pg-callout {
--pg-callout-bar: #525252;
--pg-callout-accent: #525252;
position: relative;
display: flex;
gap: 0.75rem;
margin-block: 1.25rem;
padding: 0.9rem 1rem 0.9rem 1.5rem;
padding: 0.9rem 1rem;
border: 0.5px solid color-mix(in srgb, var(--pg-callout-accent) 35%, var(--pg-code-bg));
border-radius: 0.75rem;
background-color: var(--pg-code-bg);
background-color: color-mix(in srgb, var(--pg-callout-accent) 12%, var(--pg-code-bg));
color: var(--color-fd-foreground);
font-size: 0.9rem;
overflow: hidden;
}
.pg-callout::before {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 8px;
background-color: var(--pg-callout-bar);
}
.pg-callout[data-type='note'] {
--pg-callout-bar: #2563eb;
--pg-callout-accent: #2563eb;
}
.pg-callout[data-type='info'] {
--pg-callout-bar: #525252;
--pg-callout-accent: #525252;
}
.pg-callout[data-type='tip'] {
--pg-callout-bar: #16a34a;
--pg-callout-accent: #16a34a;
}
.pg-callout[data-type='check'] {
--pg-callout-bar: #16a34a;
--pg-callout-accent: #16a34a;
}
.pg-callout[data-type='warning'] {
--pg-callout-bar: #ca8a04;
--pg-callout-accent: #ca8a04;
}
.pg-callout[data-type='danger'] {
--pg-callout-bar: #dc2626;
--pg-callout-accent: #dc2626;
}
.pg-callout-icon {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--pg-callout-bar);
color: var(--pg-callout-accent);
}
.pg-callout-icon svg {
@@ -998,13 +986,14 @@ details[open] > summary > .pg-accordion-chevron {
display: inline-flex;
align-items: center;
gap: 0.4rem;
height: 2.5rem;
padding: 0 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--color-fd-border);
background-color: #fff;
color: var(--color-fd-muted-foreground);
height: 2.25rem;
padding: 0 0.85rem;
border-radius: 0.75rem;
border: 0.5px solid var(--color-fd-border);
background-color: var(--color-fd-secondary);
color: var(--color-fd-foreground);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
@@ -1015,9 +1004,10 @@ details[open] > summary > .pg-accordion-chevron {
.pg-download:hover {
background-color: var(--color-fd-accent);
color: var(--color-fd-foreground);
color: var(--color-fd-accent-foreground);
}
.dark .pg-download {
background-color: var(--color-fd-card);
.pg-download:focus-visible {
outline: 2px solid var(--color-fd-ring);
outline-offset: 2px;
}
+2
View File
@@ -2,6 +2,7 @@ import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Analytics } from '@/components/analytics';
import { LoggedSearchDialog } from '@/components/search-dialog';
import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared';
import './global.css';
@@ -29,6 +30,7 @@ export default function Layout({ children }: LayoutProps<'/'>) {
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider
search={{ SearchDialog: LoggedSearchDialog }}
theme={
enableDarkMode
? { defaultTheme: 'light' }
+96 -16
View File
@@ -5,6 +5,7 @@ import {
type ReactNode,
type SyntheticEvent,
use,
useCallback,
useEffect,
useEffectEvent,
useMemo,
@@ -12,12 +13,24 @@ import {
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react';
import {
ArrowUp,
FileText,
Loader2,
RefreshCw,
SearchIcon,
Sparkles,
Square,
ThumbsDown,
ThumbsUp,
X,
} from 'lucide-react';
import { cn } from '../../lib/cn';
import { buttonVariants } from '../ui/button';
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { Markdown } from '../markdown';
import { getVisitorId, randomId, sendFeedback, type Vote } from '../../lib/analytics/client';
export type ChatUIMessage = UIMessage<
never,
@@ -33,6 +46,10 @@ const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<ChatUIMessage>;
/** empties the chat and starts a new analytics thread */
clear: () => void;
votes: Record<string, Vote>;
vote: (messageId: string, vote: Vote) => void;
} | null>(null);
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
@@ -52,7 +69,7 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
Ask Pangolin AI
</p>
<p className="text-xs text-fd-muted-foreground">
Answers are generated from the docs and can be wrong. Check the linked pages.
Answers are generated from the docs and can be wrong. Check the linked pages. Don't send any sensitive information.
</p>
</div>
@@ -75,7 +92,8 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const { clear } = useAISearchContext();
const { messages, status, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
@@ -107,7 +125,7 @@ export function AISearchInputActions() {
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
onClick={clear}
>
Clear Chat
</button>
@@ -323,7 +341,42 @@ function ToolActivity({ part }: { part: ToolPart }) {
);
}
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
function MessageFeedback({ messageId }: { messageId: string }) {
const { votes, vote } = useAISearchContext();
const current = votes[messageId] ?? 0;
return (
<div className="flex items-center gap-0.5 mt-1 -ms-1.5 text-fd-muted-foreground">
{([1, -1] as const).map((value) => {
const Icon = value === 1 ? ThumbsUp : ThumbsDown;
const active = current === value;
return (
<button
key={value}
type="button"
aria-label={value === 1 ? 'Good response' : 'Bad response'}
aria-pressed={active}
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
'rounded-full [&_svg]:size-3.5',
active && 'text-fd-foreground',
)}
onClick={() => vote(messageId, active ? 0 : value)}
>
<Icon className={cn(active && 'fill-current')} />
</button>
);
})}
{current !== 0 && <span className="text-xs ms-1">Thanks for the feedback</span>}
</div>
);
}
function Message({
message,
complete = true,
...props
}: { message: ChatUIMessage; complete?: boolean } & ComponentProps<'div'>) {
let markdown = '';
const toolCalls: ToolPart[] = [];
@@ -359,21 +412,44 @@ function Message({ message, ...props }: { message: ChatUIMessage } & ComponentPr
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
{message.role === 'assistant' && complete && markdown.length > 0 && (
<MessageFeedback messageId={message.id} />
)}
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat<ChatUIMessage>({
id: 'search',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [votes, setVotes] = useState<Record<string, Vote>>({});
// one analytics thread per conversation; "Clear Chat" starts a new one
const threadId = useRef<string>(null);
const [transport] = useState(
() =>
new DefaultChatTransport<ChatUIMessage>({
api: '/api/chat',
body: () => ({ threadId: (threadId.current ??= randomId()), visitorId: getVisitorId() }),
}),
);
const chat = useChat<ChatUIMessage>({ id: 'search', transport });
const { setMessages } = chat;
const clear = useCallback(() => {
setMessages([]);
setVotes({});
threadId.current = null;
}, [setMessages]);
const vote = useCallback((messageId: string, value: Vote) => {
setVotes((prev) => ({ ...prev, [messageId]: value }));
sendFeedback('message', { messageId, vote: value });
}, []);
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
<Context
value={useMemo(() => ({ chat, open, setOpen, clear, votes, vote }), [chat, open, clear, votes, vote])}
>
{children}
</Context>
);
}
@@ -463,8 +539,8 @@ export function AISearchPanel() {
<div
className={cn(
'pg-ai-panel overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
@@ -524,8 +600,12 @@ export function AISearchPanelList({ className, style, ...props }: ComponentProps
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
{messages.map((item, i) => (
<Message
key={item.id}
message={item}
complete={i < messages.length - 1 || chat.status === 'ready' || chat.status === 'error'}
/>
))}
{chat.error && (
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useState } from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@/components/ui/button';
import { sendFeedback, type Vote } from '@/lib/analytics/client';
const storageKey = (page: string) => `pg-page-vote:${page}`;
/** "Was this page helpful?" thumbs; clicking the chosen thumb again takes the vote back */
export function PageFeedback({ page }: { page: string }) {
const [vote, setVote] = useState<Vote>(0);
useEffect(() => {
try {
const stored = Number(localStorage.getItem(storageKey(page)));
setVote(stored === 1 || stored === -1 ? stored : 0);
} catch {
setVote(0);
}
}, [page]);
function choose(value: Vote) {
const next = vote === value ? 0 : value;
setVote(next);
try {
if (next === 0) localStorage.removeItem(storageKey(page));
else localStorage.setItem(storageKey(page), String(next));
} catch {
// storage unavailable
}
sendFeedback('page', { page, vote: next });
}
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-fd-muted-foreground">
<p>{vote === 0 ? 'Was this page helpful?' : 'Thanks for the feedback!'}</p>
<div className="flex gap-2">
{([1, -1] as const).map((value) => {
const Icon = value === 1 ? ThumbsUp : ThumbsDown;
const active = vote === value;
return (
<button
key={value}
type="button"
aria-pressed={active}
className={cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'rounded-full gap-1.5 px-3 [&_svg]:size-3.5',
active && 'bg-fd-accent text-fd-accent-foreground',
)}
onClick={() => choose(value)}
>
<Icon className={cn(active && 'fill-current')} />
{value === 1 ? 'Yes' : 'No'}
</button>
);
})}
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useEffect, useRef } from 'react';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
} from 'fumadocs-ui/components/dialog/search';
import type { SharedProps } from 'fumadocs-ui/contexts/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { fetchClient } from 'fumadocs-core/search/client/fetch';
import { randomId, sendEvent } from '@/lib/analytics/client';
/** a query has to sit still this long before it counts as a search */
const SETTLE_MS = 1500;
const client = fetchClient({ api: '/api/search' });
/**
* Fumadocs' default search dialog plus analytics: each search is stored once with how
* many pages matched (0 = no results) and which result, if any, was opened. Typing
* further ("dock" → "docker compose") updates the same row instead of logging prefixes.
*/
export function LoggedSearchDialog(props: SharedProps) {
const { search, setSearch, query } = useDocsSearch({ client });
const settled = useRef<{ query: string; results: number } | null>(null);
const logged = useRef<{ id: string; query: string; results: number } | null>(null);
const timer = useRef<number | undefined>(undefined);
function commit(clickedUrl?: string) {
window.clearTimeout(timer.current);
const current = settled.current;
if (!current) return;
const prev = logged.current;
if (!clickedUrl && prev?.query === current.query && prev.results === current.results) return;
const refines = prev && current.query.toLowerCase().startsWith(prev.query.toLowerCase());
logged.current = { id: refines ? prev.id : randomId(), ...current };
sendEvent('/api/analytics/search', {
id: logged.current.id,
query: current.query,
results: current.results,
clickedUrl,
page: location.pathname,
});
}
const text = search.trim();
const data = query.data;
useEffect(() => {
window.clearTimeout(timer.current);
if (!text || query.isLoading || !data || data === 'empty') {
if (!text) settled.current = null;
return;
}
// results are listed per heading/paragraph too; count the pages
settled.current = { query: text, results: data.filter((item) => item.type === 'page').length };
timer.current = window.setTimeout(() => commit(), SETTLE_MS);
return () => window.clearTimeout(timer.current);
}, [text, query.isLoading, data]);
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
onOpenChange={(open) => {
if (!open) commit();
props.onOpenChange(open);
}}
onSelect={(item) => {
if (item.type !== 'action') commit(item.url);
}}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={data && data !== 'empty' ? data : null} />
</SearchDialogContent>
</SearchDialog>
);
}
+21 -17
View File
@@ -6,29 +6,33 @@ import 'server-only';
* reverse proxy's) in front if you run several replicas.
*/
const windowMs = 60_000;
const limit = Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10);
const hits = new Map<string, number[]>();
export function checkRateLimit(key: string): { ok: boolean; retryAfter: number } {
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
export function createRateLimiter(limit: number) {
const hits = new Map<string, number[]>();
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
return function check(key: string): { ok: boolean; retryAfter: number } {
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
hits.set(key, recent);
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
}
recent.push(now);
hits.set(key, recent);
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
}
recent.push(now);
hits.set(key, recent);
// keep the map from growing forever
if (hits.size > 10_000) {
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
}
return { ok: true, retryAfter: 0 };
// keep the map from growing forever
if (hits.size > 10_000) {
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
}
return { ok: true, retryAfter: 0 };
};
}
export const checkRateLimit = createRateLimiter(Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10));
export function clientKey(req: Request) {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+143
View File
@@ -0,0 +1,143 @@
import 'server-only';
import type { ChatUIMessage } from '@/components/ai/search';
/**
* Analytics are stored by the Fossorial API (`/api/v1/docs-analytics`), called from the
* docs server with `FOSSORIAL_API_KEY` so the key never reaches the browser. With
* `FOSSORIAL_API_URL` or the key unset, events are dropped (fine for local development).
*/
const apiUrl = process.env.FOSSORIAL_API_URL?.replace(/\/+$/, '');
const apiKey = process.env.FOSSORIAL_API_KEY;
class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
let warned = false;
async function post(path: string, body: object) {
if (!apiUrl || !apiKey) {
if (!warned) console.warn('[analytics] FOSSORIAL_API_URL / FOSSORIAL_API_KEY not set; not storing analytics');
warned = true;
return;
}
const res = await fetch(`${apiUrl}/api/v1/docs-analytics${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
cache: 'no-store',
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new ApiError(res.status, `Fossorial API ${path} responded ${res.status} ${detail.slice(0, 300)}`);
}
}
/** ids come from the browser: keep them short and boring */
export function parseId(value: unknown): string | null {
return typeof value === 'string' && /^[\w-]{8,64}$/.test(value) ? value : null;
}
export function parseVote(value: unknown): -1 | 0 | 1 | null {
return value === 1 || value === -1 || value === 0 ? value : null;
}
/** `vote: 0` clears a previous vote */
export async function recordPageVote(page: string, visitorId: string, vote: -1 | 0 | 1) {
await post('/page-votes', { page, visitorId, vote });
}
/** returns false when the message isn't a stored assistant response */
export async function recordMessageVote(
messageId: string,
visitorId: string | null,
vote: -1 | 0 | 1,
): Promise<boolean> {
try {
await post('/message-votes', { messageId, visitorId, vote });
return true;
} catch (e) {
if (e instanceof ApiError && e.status === 404) return false;
throw e;
}
}
function messageText(message: ChatUIMessage) {
return message.parts
.flatMap((p) => (p.type === 'text' ? [p.text] : []))
.join('')
.trim();
}
function messagePage(message: ChatUIMessage) {
for (const part of message.parts) {
if (part.type !== 'data-client') continue;
try {
const url = new URL(part.data.location);
return `${url.pathname}${url.search}${url.hash}`.slice(0, 2048);
} catch {
return null;
}
}
return null;
}
/** creates the thread on its first question; re-sent messages (regenerate) are ignored */
export async function recordUserMessage(
threadId: string,
visitorId: string | null,
message: ChatUIMessage,
) {
const id = parseId(message.id);
const content = messageText(message);
if (!id || !content) return;
await post('/chat-messages', {
threadId,
visitorId,
page: messagePage(message),
id,
role: 'user',
content,
});
}
export async function recordAssistantMessage(threadId: string, message: ChatUIMessage) {
const id = parseId(message.id);
if (!id) return;
const tools = message.parts.flatMap((p) => {
if (!p.type.startsWith('tool-')) return [];
const input = (p as { input?: unknown }).input;
return [{ name: p.type.slice('tool-'.length), input }];
});
await post('/chat-messages', {
threadId,
id,
role: 'assistant',
content: messageText(message),
tools,
});
}
export interface SearchEvent {
id: string;
visitorId: string | null;
query: string;
results: number;
clickedUrl: string | null;
page: string | null;
}
/** one row per search; the dialog re-sends the same id as the query is refined */
export async function recordSearch(e: SearchEvent) {
await post('/searches', e);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Browser helpers for docs feedback. The visitor id is an anonymous random id kept in
* localStorage so a person can change their vote; nothing else identifies them.
*/
const VisitorKey = 'pg-visitor-id';
export function randomId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID();
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
let fallbackVisitorId: string | undefined;
export function getVisitorId() {
try {
let id = localStorage.getItem(VisitorKey);
if (!id) {
id = randomId();
localStorage.setItem(VisitorKey, id);
}
return id;
} catch {
// storage unavailable (private mode): one id per page load
return (fallbackVisitorId ??= randomId());
}
}
export type Vote = -1 | 0 | 1;
/** fire-and-forget; analytics must never break the page */
export function sendEvent(url: string, body: Record<string, unknown>) {
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, visitorId: getVisitorId() }),
keepalive: true,
}).catch(() => undefined);
}
export function sendFeedback(kind: 'page' | 'message', body: Record<string, unknown>) {
sendEvent(`/api/feedback/${kind}`, body);
}
+21
View File
@@ -0,0 +1,21 @@
import 'server-only';
import { clientKey, createRateLimiter } from '@/lib/ai/rate-limit';
const checkFeedbackLimit = createRateLimiter(Number(process.env.FEEDBACK_RATE_LIMIT_PER_MINUTE ?? 60));
export function json(status: number, body: object) {
return Response.json(body, { status });
}
/** shared guard for the feedback endpoints: rate limit + JSON body */
export async function readFeedback(req: Request): Promise<Record<string, unknown> | Response> {
if (!checkFeedbackLimit(clientKey(req)).ok) return json(429, { error: 'Too many requests.' });
const body = (await req.json().catch(() => null)) as unknown;
if (!body || typeof body !== 'object') return json(400, { error: 'Invalid body.' });
return body as Record<string, unknown>;
}
export function storageError(scope: string, error: unknown) {
console.error(`[analytics] ${scope}`, error);
return json(503, { error: 'Feedback storage is unavailable.' });
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function baseOptions(): BaseLayoutProps {
},
themeSwitch: { enabled: enableDarkMode },
links: [
// pill buttons styled like the pangolin.net navbar (secondary + primary)
// same height and radius as the header search and Ask AI button
{
type: 'custom',
on: 'nav',
+1 -1
View File
@@ -30,5 +30,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "analytics-dashboard"]
}