mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-10-15 00:59:09 +02:00
add search, chat, and feedback analytics with dashboard
This commit is contained in:
@@ -5,3 +5,4 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
analytics-dashboard
|
||||||
|
|||||||
@@ -35,3 +35,16 @@ NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net
|
|||||||
|
|
||||||
# Set to 1 to disable PostHog / Rybbit / Reo in production builds
|
# Set to 1 to disable PostHog / Rybbit / Reo in production builds
|
||||||
# NEXT_PUBLIC_DISABLE_ANALYTICS=
|
# 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
|
||||||
|
|||||||
@@ -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.
|
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
|
## Writing docs
|
||||||
|
|
||||||
- Pages live in `content/docs/**/*.mdx`. The URL is the file path.
|
- Pages live in `content/docs/**/*.mdx`. The URL is the file path.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/node_modules
|
||||||
|
/.next/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
@@ -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 -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+3932
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}%`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function cn(...classes: (string | false | null | undefined)[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { AskAIAboutPage } from '@/components/ai/ask-page';
|
|||||||
import { SiteFooter } from '@/components/site-footer';
|
import { SiteFooter } from '@/components/site-footer';
|
||||||
import { AskBar } from '@/components/ai/ask-bar';
|
import { AskBar } from '@/components/ai/ask-bar';
|
||||||
import { pageJsonLd } from '@/lib/json-ld';
|
import { pageJsonLd } from '@/lib/json-ld';
|
||||||
|
import { PageFeedback } from '@/components/page-feedback';
|
||||||
|
|
||||||
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -36,6 +37,7 @@ export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
|||||||
footer={{
|
footer={{
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
|
<PageFeedback page={page.url} />
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<AskBar />
|
<AskBar />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,8 @@
|
|||||||
|
import { after } from 'next/server';
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStreamResponse,
|
createUIMessageStreamResponse,
|
||||||
|
generateId,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
toUIMessageStream,
|
toUIMessageStream,
|
||||||
@@ -11,6 +13,7 @@ import { AIConfigError, getModel } from '@/lib/ai/model';
|
|||||||
import { getSystemPrompt } from '@/lib/ai/prompt';
|
import { getSystemPrompt } from '@/lib/ai/prompt';
|
||||||
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
|
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
|
||||||
import { tools } from '@/lib/ai/tools';
|
import { tools } from '@/lib/ai/tools';
|
||||||
|
import { parseId, recordAssistantMessage, recordUserMessage } from '@/lib/analytics/api';
|
||||||
|
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
@@ -37,13 +40,31 @@ export async function POST(req: Request) {
|
|||||||
throw e;
|
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 messages = (body?.messages ?? []).slice(-MAX_MESSAGES);
|
||||||
const tooLong = messages.some((m) =>
|
const tooLong = messages.some((m) =>
|
||||||
m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS),
|
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.');
|
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 = {
|
const instructions: SystemModelMessage = {
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: getSystemPrompt(),
|
content: getSystemPrompt(),
|
||||||
@@ -74,6 +95,13 @@ export async function POST(req: Request) {
|
|||||||
return createUIMessageStreamResponse({
|
return createUIMessageStreamResponse({
|
||||||
stream: toUIMessageStream({
|
stream: toUIMessageStream({
|
||||||
stream: result.stream,
|
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.',
|
onError: () => 'The assistant hit an error. Please try again.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
@@ -74,23 +74,6 @@ body {
|
|||||||
color: var(--color-fd-foreground);
|
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
|
* 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.
|
* 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);
|
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 */
|
/* separators = Mintlify top-level group headings */
|
||||||
#nd-sidebar p[data-separator],
|
#nd-sidebar p[data-separator],
|
||||||
#nd-sidebar [data-separator] {
|
#nd-sidebar [data-separator] {
|
||||||
@@ -255,44 +250,46 @@ body {
|
|||||||
color: #faf9f2;
|
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 {
|
.pg-btn {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
border-radius: 9999px;
|
align-items: center;
|
||||||
border: 0;
|
justify-content: center;
|
||||||
background-color: #202020;
|
height: 2.25rem;
|
||||||
color: #faf9f2;
|
padding: 0 0.85rem;
|
||||||
font-family: 'Die Grotesk B', var(--font-sans);
|
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-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1;
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform 300ms;
|
transition:
|
||||||
|
background-color 150ms,
|
||||||
|
color 150ms,
|
||||||
|
border-color 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pg-btn:hover {
|
.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 {
|
.pg-btn-secondary {
|
||||||
border: 1px solid #bbbbbb;
|
border-color: var(--color-fd-border);
|
||||||
background-color: #faf9f2;
|
background-color: var(--color-fd-background);
|
||||||
color: #202020;
|
color: var(--color-fd-foreground);
|
||||||
/* 1px border: keep the same outer size as the primary button */
|
|
||||||
padding: calc(0.75rem - 1px) calc(1.25rem - 1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .pg-btn {
|
.pg-btn-secondary:hover {
|
||||||
background-color: #ecebe6;
|
background-color: var(--color-fd-accent);
|
||||||
color: #161614;
|
color: var(--color-fd-accent-foreground);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .pg-btn-secondary {
|
|
||||||
border-color: #3a3934;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #ecebe6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Ask AI" button to the right of the header search (Mintlify style) */
|
/* "Ask AI" button to the right of the header search (Mintlify style) */
|
||||||
@@ -500,53 +497,44 @@ figure.shiki .line.highlighted {
|
|||||||
font-size: 0.875rem;
|
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 {
|
||||||
--pg-callout-bar: #525252;
|
--pg-callout-accent: #525252;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-block: 1.25rem;
|
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;
|
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);
|
color: var(--color-fd-foreground);
|
||||||
font-size: 0.9rem;
|
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[data-type='note'] {
|
||||||
--pg-callout-bar: #2563eb;
|
--pg-callout-accent: #2563eb;
|
||||||
}
|
}
|
||||||
.pg-callout[data-type='info'] {
|
.pg-callout[data-type='info'] {
|
||||||
--pg-callout-bar: #525252;
|
--pg-callout-accent: #525252;
|
||||||
}
|
}
|
||||||
.pg-callout[data-type='tip'] {
|
.pg-callout[data-type='tip'] {
|
||||||
--pg-callout-bar: #16a34a;
|
--pg-callout-accent: #16a34a;
|
||||||
}
|
}
|
||||||
.pg-callout[data-type='check'] {
|
.pg-callout[data-type='check'] {
|
||||||
--pg-callout-bar: #16a34a;
|
--pg-callout-accent: #16a34a;
|
||||||
}
|
}
|
||||||
.pg-callout[data-type='warning'] {
|
.pg-callout[data-type='warning'] {
|
||||||
--pg-callout-bar: #ca8a04;
|
--pg-callout-accent: #ca8a04;
|
||||||
}
|
}
|
||||||
.pg-callout[data-type='danger'] {
|
.pg-callout[data-type='danger'] {
|
||||||
--pg-callout-bar: #dc2626;
|
--pg-callout-accent: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pg-callout-icon {
|
.pg-callout-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
color: var(--pg-callout-bar);
|
color: var(--pg-callout-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pg-callout-icon svg {
|
.pg-callout-icon svg {
|
||||||
@@ -998,13 +986,14 @@ details[open] > summary > .pg-accordion-chevron {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
height: 2.5rem;
|
height: 2.25rem;
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.85rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid var(--color-fd-border);
|
border: 0.5px solid var(--color-fd-border);
|
||||||
background-color: #fff;
|
background-color: var(--color-fd-secondary);
|
||||||
color: var(--color-fd-muted-foreground);
|
color: var(--color-fd-foreground);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1015,9 +1004,10 @@ details[open] > summary > .pg-accordion-chevron {
|
|||||||
|
|
||||||
.pg-download:hover {
|
.pg-download:hover {
|
||||||
background-color: var(--color-fd-accent);
|
background-color: var(--color-fd-accent);
|
||||||
color: var(--color-fd-foreground);
|
color: var(--color-fd-accent-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .pg-download {
|
.pg-download:focus-visible {
|
||||||
background-color: var(--color-fd-card);
|
outline: 2px solid var(--color-fd-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RootProvider } from 'fumadocs-ui/provider/next';
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { Analytics } from '@/components/analytics';
|
import { Analytics } from '@/components/analytics';
|
||||||
|
import { LoggedSearchDialog } from '@/components/search-dialog';
|
||||||
import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared';
|
import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared';
|
||||||
import './global.css';
|
import './global.css';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export default function Layout({ children }: LayoutProps<'/'>) {
|
|||||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||||
<body className="flex flex-col min-h-screen">
|
<body className="flex flex-col min-h-screen">
|
||||||
<RootProvider
|
<RootProvider
|
||||||
|
search={{ SearchDialog: LoggedSearchDialog }}
|
||||||
theme={
|
theme={
|
||||||
enableDarkMode
|
enableDarkMode
|
||||||
? { defaultTheme: 'light' }
|
? { defaultTheme: 'light' }
|
||||||
|
|||||||
+96
-16
@@ -5,6 +5,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SyntheticEvent,
|
type SyntheticEvent,
|
||||||
use,
|
use,
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useEffectEvent,
|
useEffectEvent,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -12,12 +13,24 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
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 { cn } from '../../lib/cn';
|
||||||
import { buttonVariants } from '../ui/button';
|
import { buttonVariants } from '../ui/button';
|
||||||
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
|
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
|
||||||
import { DefaultChatTransport, type UIMessage } from 'ai';
|
import { DefaultChatTransport, type UIMessage } from 'ai';
|
||||||
import { Markdown } from '../markdown';
|
import { Markdown } from '../markdown';
|
||||||
|
import { getVisitorId, randomId, sendFeedback, type Vote } from '../../lib/analytics/client';
|
||||||
|
|
||||||
export type ChatUIMessage = UIMessage<
|
export type ChatUIMessage = UIMessage<
|
||||||
never,
|
never,
|
||||||
@@ -33,6 +46,10 @@ const Context = createContext<{
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
chat: UseChatHelpers<ChatUIMessage>;
|
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);
|
} | null>(null);
|
||||||
|
|
||||||
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
||||||
@@ -52,7 +69,7 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
|
|||||||
Ask Pangolin AI
|
Ask Pangolin AI
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-fd-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +92,8 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AISearchInputActions() {
|
export function AISearchInputActions() {
|
||||||
const { messages, status, setMessages, regenerate } = useChatContext();
|
const { clear } = useAISearchContext();
|
||||||
|
const { messages, status, regenerate } = useChatContext();
|
||||||
const isLoading = status === 'streaming';
|
const isLoading = status === 'streaming';
|
||||||
|
|
||||||
if (messages.length === 0) return null;
|
if (messages.length === 0) return null;
|
||||||
@@ -107,7 +125,7 @@ export function AISearchInputActions() {
|
|||||||
className: 'rounded-full',
|
className: 'rounded-full',
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
onClick={() => setMessages([])}
|
onClick={clear}
|
||||||
>
|
>
|
||||||
Clear Chat
|
Clear Chat
|
||||||
</button>
|
</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 = '';
|
let markdown = '';
|
||||||
const toolCalls: ToolPart[] = [];
|
const toolCalls: ToolPart[] = [];
|
||||||
|
|
||||||
@@ -359,21 +412,44 @@ function Message({ message, ...props }: { message: ChatUIMessage } & ComponentPr
|
|||||||
<div className="prose text-sm">
|
<div className="prose text-sm">
|
||||||
<Markdown text={markdown} />
|
<Markdown text={markdown} />
|
||||||
</div>
|
</div>
|
||||||
|
{message.role === 'assistant' && complete && markdown.length > 0 && (
|
||||||
|
<MessageFeedback messageId={message.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AISearch({ children }: { children: ReactNode }) {
|
export function AISearch({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const chat = useChat<ChatUIMessage>({
|
const [votes, setVotes] = useState<Record<string, Vote>>({});
|
||||||
id: 'search',
|
// one analytics thread per conversation; "Clear Chat" starts a new one
|
||||||
transport: new DefaultChatTransport({
|
const threadId = useRef<string>(null);
|
||||||
api: '/api/chat',
|
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 (
|
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
|
<div
|
||||||
className={cn(
|
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]',
|
'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',
|
'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:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
|
'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
|
open
|
||||||
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
||||||
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
||||||
@@ -524,8 +600,12 @@ export function AISearchPanelList({ className, style, ...props }: ComponentProps
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col px-3 gap-4">
|
<div className="flex flex-col px-3 gap-4">
|
||||||
{messages.map((item) => (
|
{messages.map((item, i) => (
|
||||||
<Message key={item.id} message={item} />
|
<Message
|
||||||
|
key={item.id}
|
||||||
|
message={item}
|
||||||
|
complete={i < messages.length - 1 || chat.status === 'ready' || chat.status === 'error'}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{chat.error && (
|
{chat.error && (
|
||||||
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
|
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -6,29 +6,33 @@ import 'server-only';
|
|||||||
* reverse proxy's) in front if you run several replicas.
|
* reverse proxy's) in front if you run several replicas.
|
||||||
*/
|
*/
|
||||||
const windowMs = 60_000;
|
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 } {
|
export function createRateLimiter(limit: number) {
|
||||||
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
|
const hits = new Map<string, number[]>();
|
||||||
|
|
||||||
const now = Date.now();
|
return function check(key: string): { ok: boolean; retryAfter: number } {
|
||||||
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
|
||||||
if (recent.length >= limit) {
|
|
||||||
|
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);
|
hits.set(key, recent);
|
||||||
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
|
|
||||||
}
|
|
||||||
|
|
||||||
recent.push(now);
|
// keep the map from growing forever
|
||||||
hits.set(key, recent);
|
if (hits.size > 10_000) {
|
||||||
|
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
|
||||||
// keep the map from growing forever
|
}
|
||||||
if (hits.size > 10_000) {
|
return { ok: true, retryAfter: 0 };
|
||||||
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) {
|
export function clientKey(req: Request) {
|
||||||
return (
|
return (
|
||||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.' });
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export function baseOptions(): BaseLayoutProps {
|
|||||||
},
|
},
|
||||||
themeSwitch: { enabled: enableDarkMode },
|
themeSwitch: { enabled: enableDarkMode },
|
||||||
links: [
|
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',
|
type: 'custom',
|
||||||
on: 'nav',
|
on: 'nav',
|
||||||
|
|||||||
+1
-1
@@ -30,5 +30,5 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "analytics-dashboard"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user