add search, chat, and feedback analytics with dashboard

This commit is contained in:
miloschwartz
2026-09-28 14:03:29 -04:00
parent 9d1b9f3820
commit 6588fc870b
42 changed files with 5932 additions and 108 deletions
+96 -16
View File
@@ -5,6 +5,7 @@ import {
type ReactNode,
type SyntheticEvent,
use,
useCallback,
useEffect,
useEffectEvent,
useMemo,
@@ -12,12 +13,24 @@ import {
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react';
import {
ArrowUp,
FileText,
Loader2,
RefreshCw,
SearchIcon,
Sparkles,
Square,
ThumbsDown,
ThumbsUp,
X,
} from 'lucide-react';
import { cn } from '../../lib/cn';
import { buttonVariants } from '../ui/button';
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { Markdown } from '../markdown';
import { getVisitorId, randomId, sendFeedback, type Vote } from '../../lib/analytics/client';
export type ChatUIMessage = UIMessage<
never,
@@ -33,6 +46,10 @@ const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<ChatUIMessage>;
/** empties the chat and starts a new analytics thread */
clear: () => void;
votes: Record<string, Vote>;
vote: (messageId: string, vote: Vote) => void;
} | null>(null);
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
@@ -52,7 +69,7 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
Ask Pangolin AI
</p>
<p className="text-xs text-fd-muted-foreground">
Answers are generated from the docs and can be wrong. Check the linked pages.
Answers are generated from the docs and can be wrong. Check the linked pages. Don't send any sensitive information.
</p>
</div>
@@ -75,7 +92,8 @@ export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const { clear } = useAISearchContext();
const { messages, status, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
@@ -107,7 +125,7 @@ export function AISearchInputActions() {
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
onClick={clear}
>
Clear Chat
</button>
@@ -323,7 +341,42 @@ function ToolActivity({ part }: { part: ToolPart }) {
);
}
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
function MessageFeedback({ messageId }: { messageId: string }) {
const { votes, vote } = useAISearchContext();
const current = votes[messageId] ?? 0;
return (
<div className="flex items-center gap-0.5 mt-1 -ms-1.5 text-fd-muted-foreground">
{([1, -1] as const).map((value) => {
const Icon = value === 1 ? ThumbsUp : ThumbsDown;
const active = current === value;
return (
<button
key={value}
type="button"
aria-label={value === 1 ? 'Good response' : 'Bad response'}
aria-pressed={active}
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
'rounded-full [&_svg]:size-3.5',
active && 'text-fd-foreground',
)}
onClick={() => vote(messageId, active ? 0 : value)}
>
<Icon className={cn(active && 'fill-current')} />
</button>
);
})}
{current !== 0 && <span className="text-xs ms-1">Thanks for the feedback</span>}
</div>
);
}
function Message({
message,
complete = true,
...props
}: { message: ChatUIMessage; complete?: boolean } & ComponentProps<'div'>) {
let markdown = '';
const toolCalls: ToolPart[] = [];
@@ -359,21 +412,44 @@ function Message({ message, ...props }: { message: ChatUIMessage } & ComponentPr
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
{message.role === 'assistant' && complete && markdown.length > 0 && (
<MessageFeedback messageId={message.id} />
)}
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat<ChatUIMessage>({
id: 'search',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [votes, setVotes] = useState<Record<string, Vote>>({});
// one analytics thread per conversation; "Clear Chat" starts a new one
const threadId = useRef<string>(null);
const [transport] = useState(
() =>
new DefaultChatTransport<ChatUIMessage>({
api: '/api/chat',
body: () => ({ threadId: (threadId.current ??= randomId()), visitorId: getVisitorId() }),
}),
);
const chat = useChat<ChatUIMessage>({ id: 'search', transport });
const { setMessages } = chat;
const clear = useCallback(() => {
setMessages([]);
setVotes({});
threadId.current = null;
}, [setMessages]);
const vote = useCallback((messageId: string, value: Vote) => {
setVotes((prev) => ({ ...prev, [messageId]: value }));
sendFeedback('message', { messageId, vote: value });
}, []);
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
<Context
value={useMemo(() => ({ chat, open, setOpen, clear, votes, vote }), [chat, open, clear, votes, vote])}
>
{children}
</Context>
);
}
@@ -463,8 +539,8 @@ export function AISearchPanel() {
<div
className={cn(
'pg-ai-panel overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
@@ -524,8 +600,12 @@ export function AISearchPanelList({ className, style, ...props }: ComponentProps
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
{messages.map((item, i) => (
<Message
key={item.id}
message={item}
complete={i < messages.length - 1 || chat.status === 'ready' || chat.status === 'error'}
/>
))}
{chat.error && (
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useState } from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@/components/ui/button';
import { sendFeedback, type Vote } from '@/lib/analytics/client';
const storageKey = (page: string) => `pg-page-vote:${page}`;
/** "Was this page helpful?" thumbs; clicking the chosen thumb again takes the vote back */
export function PageFeedback({ page }: { page: string }) {
const [vote, setVote] = useState<Vote>(0);
useEffect(() => {
try {
const stored = Number(localStorage.getItem(storageKey(page)));
setVote(stored === 1 || stored === -1 ? stored : 0);
} catch {
setVote(0);
}
}, [page]);
function choose(value: Vote) {
const next = vote === value ? 0 : value;
setVote(next);
try {
if (next === 0) localStorage.removeItem(storageKey(page));
else localStorage.setItem(storageKey(page), String(next));
} catch {
// storage unavailable
}
sendFeedback('page', { page, vote: next });
}
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-fd-muted-foreground">
<p>{vote === 0 ? 'Was this page helpful?' : 'Thanks for the feedback!'}</p>
<div className="flex gap-2">
{([1, -1] as const).map((value) => {
const Icon = value === 1 ? ThumbsUp : ThumbsDown;
const active = vote === value;
return (
<button
key={value}
type="button"
aria-pressed={active}
className={cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'rounded-full gap-1.5 px-3 [&_svg]:size-3.5',
active && 'bg-fd-accent text-fd-accent-foreground',
)}
onClick={() => choose(value)}
>
<Icon className={cn(active && 'fill-current')} />
{value === 1 ? 'Yes' : 'No'}
</button>
);
})}
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useEffect, useRef } from 'react';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
} from 'fumadocs-ui/components/dialog/search';
import type { SharedProps } from 'fumadocs-ui/contexts/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { fetchClient } from 'fumadocs-core/search/client/fetch';
import { randomId, sendEvent } from '@/lib/analytics/client';
/** a query has to sit still this long before it counts as a search */
const SETTLE_MS = 1500;
const client = fetchClient({ api: '/api/search' });
/**
* Fumadocs' default search dialog plus analytics: each search is stored once with how
* many pages matched (0 = no results) and which result, if any, was opened. Typing
* further ("dock" → "docker compose") updates the same row instead of logging prefixes.
*/
export function LoggedSearchDialog(props: SharedProps) {
const { search, setSearch, query } = useDocsSearch({ client });
const settled = useRef<{ query: string; results: number } | null>(null);
const logged = useRef<{ id: string; query: string; results: number } | null>(null);
const timer = useRef<number | undefined>(undefined);
function commit(clickedUrl?: string) {
window.clearTimeout(timer.current);
const current = settled.current;
if (!current) return;
const prev = logged.current;
if (!clickedUrl && prev?.query === current.query && prev.results === current.results) return;
const refines = prev && current.query.toLowerCase().startsWith(prev.query.toLowerCase());
logged.current = { id: refines ? prev.id : randomId(), ...current };
sendEvent('/api/analytics/search', {
id: logged.current.id,
query: current.query,
results: current.results,
clickedUrl,
page: location.pathname,
});
}
const text = search.trim();
const data = query.data;
useEffect(() => {
window.clearTimeout(timer.current);
if (!text || query.isLoading || !data || data === 'empty') {
if (!text) settled.current = null;
return;
}
// results are listed per heading/paragraph too; count the pages
settled.current = { query: text, results: data.filter((item) => item.type === 'page').length };
timer.current = window.setTimeout(() => commit(), SETTLE_MS);
return () => window.clearTimeout(timer.current);
}, [text, query.isLoading, data]);
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
onOpenChange={(open) => {
if (!open) commit();
props.onOpenChange(open);
}}
onSelect={(item) => {
if (item.type !== 'action') commit(item.url);
}}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={data && data !== 'empty' ? data : null} />
</SearchDialogContent>
</SearchDialog>
);
}