mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-30 17:59:10 +02:00
add search, chat, and feedback analytics with dashboard
This commit is contained in:
+96
-16
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user