mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-26 07:49:08 +02:00
581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
'use client';
|
|
import {
|
|
type ComponentProps,
|
|
createContext,
|
|
type ReactNode,
|
|
type SyntheticEvent,
|
|
use,
|
|
useEffect,
|
|
useEffectEvent,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { flushSync } from 'react-dom';
|
|
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, 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';
|
|
|
|
export type ChatUIMessage = UIMessage<
|
|
never,
|
|
{
|
|
client: {
|
|
location: string;
|
|
};
|
|
}
|
|
>;
|
|
|
|
|
|
const Context = createContext<{
|
|
open: boolean;
|
|
setOpen: (open: boolean) => void;
|
|
chat: UseChatHelpers<ChatUIMessage>;
|
|
} | null>(null);
|
|
|
|
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
|
|
const { setOpen } = useAISearchContext();
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
<div className="px-3 py-2 flex-1">
|
|
<p className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
|
<Sparkles className="size-3.5 text-fd-primary" />
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
aria-label="Close"
|
|
tabIndex={-1}
|
|
className={cn(
|
|
buttonVariants({
|
|
size: 'icon-sm',
|
|
variant: 'ghost',
|
|
className: 'text-fd-muted-foreground rounded-full',
|
|
}),
|
|
)}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<X />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AISearchInputActions() {
|
|
const { messages, status, setMessages, regenerate } = useChatContext();
|
|
const isLoading = status === 'streaming';
|
|
|
|
if (messages.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
{!isLoading && messages.at(-1)?.role === 'assistant' && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
buttonVariants({
|
|
variant: 'secondary',
|
|
size: 'sm',
|
|
className: 'rounded-full gap-1.5',
|
|
}),
|
|
)}
|
|
onClick={() => regenerate()}
|
|
>
|
|
<RefreshCw className="size-4" />
|
|
Retry
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
buttonVariants({
|
|
variant: 'secondary',
|
|
size: 'sm',
|
|
className: 'rounded-full',
|
|
}),
|
|
)}
|
|
onClick={() => setMessages([])}
|
|
>
|
|
Clear Chat
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const suggestions = [
|
|
'How do I expose a web app running on my home server?',
|
|
'What is the difference between a site and a client?',
|
|
'How do I self-host Pangolin with Docker Compose?',
|
|
'How do I connect Claude Code to the AI Gateway?',
|
|
];
|
|
|
|
function sendText(send: UseChatHelpers<ChatUIMessage>['sendMessage'], text: string) {
|
|
void send({
|
|
role: 'user',
|
|
parts: [
|
|
{ type: 'data-client', data: { location: location.href } },
|
|
{ type: 'text', text },
|
|
],
|
|
});
|
|
}
|
|
|
|
/** open the panel and immediately ask `text` */
|
|
export function useAskAI() {
|
|
const { setOpen, chat } = useAISearchContext();
|
|
return (text: string) => {
|
|
setOpen(true);
|
|
if (chat.status === 'streaming' || chat.status === 'submitted') return;
|
|
sendText(chat.sendMessage, text);
|
|
};
|
|
}
|
|
|
|
const StorageKeyInput = '__ai_search_input';
|
|
export function AISearchInput(props: ComponentProps<'form'>) {
|
|
const { status, sendMessage, stop } = useChatContext();
|
|
const [input, setInput] = useState(() => {
|
|
try {
|
|
return localStorage.getItem(StorageKeyInput) ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
});
|
|
const isLoading = status === 'streaming' || status === 'submitted';
|
|
const onStart = (e?: SyntheticEvent) => {
|
|
e?.preventDefault();
|
|
const message = input.trim();
|
|
if (message.length === 0) return;
|
|
|
|
sendText(sendMessage, message);
|
|
setInput('');
|
|
try {
|
|
localStorage.removeItem(StorageKeyInput);
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isLoading) document.getElementById('nd-ai-input')?.focus();
|
|
}, [isLoading]);
|
|
|
|
return (
|
|
<form {...props} className={cn('flex items-end gap-2 p-2', props.className)} onSubmit={onStart}>
|
|
<Input
|
|
value={input}
|
|
placeholder={isLoading ? 'Pangolin AI is answering…' : 'Ask a question...'}
|
|
autoFocus
|
|
className="px-2 py-1.5 text-sm"
|
|
disabled={status === 'streaming' || status === 'submitted'}
|
|
onChange={(e) => {
|
|
setInput(e.target.value);
|
|
try {
|
|
localStorage.setItem(StorageKeyInput, e.target.value);
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
// keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false
|
|
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
if (!event.shiftKey && event.key === 'Enter') {
|
|
onStart(event);
|
|
}
|
|
}}
|
|
/>
|
|
{isLoading ? (
|
|
<button key="bn" type="button" className="pg-chat-send" aria-label="Stop answering" onClick={stop}>
|
|
<Square className="size-3 fill-current" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
key="bn"
|
|
type="submit"
|
|
className="pg-chat-send"
|
|
aria-label="Send"
|
|
disabled={input.trim().length === 0}
|
|
>
|
|
<ArrowUp className="size-4" />
|
|
</button>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
function callback() {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
container.scrollTo({
|
|
top: container.scrollHeight,
|
|
behavior: 'instant',
|
|
});
|
|
}
|
|
|
|
const observer = new ResizeObserver(callback);
|
|
callback();
|
|
|
|
const element = containerRef.current?.firstElementChild;
|
|
|
|
if (element) {
|
|
observer.observe(element);
|
|
}
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
{...props}
|
|
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Input(props: ComponentProps<'textarea'>) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const shared = cn('pg-chat-field col-start-1 row-start-1', props.className);
|
|
|
|
return (
|
|
<div className="grid flex-1">
|
|
<textarea
|
|
id="nd-ai-input"
|
|
{...props}
|
|
className={cn(
|
|
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
|
|
shared,
|
|
)}
|
|
/>
|
|
<div ref={ref} className={cn(shared, 'break-all invisible')}>
|
|
{`${props.value?.toString() ?? ''}\n`}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const roleName: Record<string, string> = {
|
|
user: 'You',
|
|
assistant: 'Pangolin AI',
|
|
};
|
|
|
|
interface ToolPart {
|
|
type: string;
|
|
toolCallId: string;
|
|
state: string;
|
|
input?: { query?: string; path?: string };
|
|
output?: unknown;
|
|
errorText?: string;
|
|
}
|
|
|
|
function ToolActivity({ part }: { part: ToolPart }) {
|
|
const name = part.type.slice('tool-'.length);
|
|
const failed = part.state === 'output-error' || part.state === 'output-denied';
|
|
const done = part.state === 'output-available';
|
|
|
|
let label: ReactNode;
|
|
if (name === 'search_docs') {
|
|
const count = Array.isArray(part.output) ? part.output.length : 0;
|
|
label = done ? (
|
|
<>
|
|
Searched <q>{part.input?.query}</q>, {count} {count === 1 ? 'page' : 'pages'}
|
|
</>
|
|
) : (
|
|
<>Searching <q>{part.input?.query ?? '…'}</q></>
|
|
);
|
|
} else if (name === 'read_page') {
|
|
label = <>{done ? 'Read' : 'Reading'} <code>{part.input?.path ?? '…'}</code></>;
|
|
} else {
|
|
label = name;
|
|
}
|
|
|
|
const Icon = name === 'read_page' ? FileText : SearchIcon;
|
|
return (
|
|
<div className="flex flex-row gap-2 items-center rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs px-2 py-1.5 min-w-0">
|
|
{done || failed ? <Icon className="size-3.5 shrink-0" /> : <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
|
{failed ? (
|
|
<p className="text-fd-error truncate">{part.errorText ?? 'Tool call failed'}</p>
|
|
) : (
|
|
<p className="truncate">{label}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
|
|
let markdown = '';
|
|
const toolCalls: ToolPart[] = [];
|
|
|
|
for (const part of message.parts ?? []) {
|
|
if (part.type === 'text') {
|
|
markdown += part.text;
|
|
continue;
|
|
}
|
|
|
|
if (part.type.startsWith('tool-')) {
|
|
const p = part as unknown as ToolPart;
|
|
if (p.toolCallId) toolCalls.push(p);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div onClick={(e) => e.stopPropagation()} {...props}>
|
|
<p
|
|
className={cn(
|
|
'mb-1 text-sm font-medium text-fd-muted-foreground',
|
|
message.role === 'assistant' && 'text-fd-primary',
|
|
)}
|
|
>
|
|
{roleName[message.role] ?? 'unknown'}
|
|
</p>
|
|
{toolCalls.length > 0 && (
|
|
<div className="flex flex-col gap-1 mb-2">
|
|
{toolCalls.map((call) => (
|
|
<ToolActivity key={call.toolCallId} part={call} />
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="prose text-sm">
|
|
<Markdown text={markdown} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AISearch({ children }: { children: ReactNode }) {
|
|
const [open, setOpen] = useState(false);
|
|
const chat = useChat<ChatUIMessage>({
|
|
id: 'search',
|
|
transport: new DefaultChatTransport({
|
|
api: '/api/chat',
|
|
}),
|
|
});
|
|
|
|
return (
|
|
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
|
|
);
|
|
}
|
|
|
|
export function AISearchTrigger({
|
|
position = 'default',
|
|
className,
|
|
...props
|
|
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
|
|
const { open, setOpen } = useAISearchContext();
|
|
|
|
return (
|
|
<button
|
|
data-state={open ? 'open' : 'closed'}
|
|
className={cn(
|
|
position === 'float' && [
|
|
'fixed bottom-4 gap-2 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
|
|
open && 'translate-y-10 opacity-0',
|
|
],
|
|
className,
|
|
)}
|
|
onClick={() => setOpen(!open)}
|
|
{...props}
|
|
>
|
|
{props.children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function AISearchPanel() {
|
|
const { open, setOpen } = useAISearchContext();
|
|
const [actualOpen, setActualOpen] = useState(open);
|
|
useHotKey();
|
|
|
|
if (open && !actualOpen) setActualOpen(open);
|
|
|
|
// The docs grid reserves a column for the panel while this attribute is set (see
|
|
// `--pg-panel` in app/global.css). It follows `open` directly, so the content moves
|
|
// back as soon as the panel starts closing, whatever happens to the close animation.
|
|
useEffect(() => {
|
|
const layout = document.getElementById('nd-notebook-layout');
|
|
layout?.toggleAttribute('data-ai-open', open);
|
|
return () => layout?.removeAttribute('data-ai-open');
|
|
}, [open]);
|
|
|
|
// Unmount after the close animation. `animationend` never fires when animations are
|
|
// skipped (background tab, reduced motion), so also fall back to a timer.
|
|
useEffect(() => {
|
|
if (open || !actualOpen) return;
|
|
const timer = window.setTimeout(() => setActualOpen(false), 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, [open, actualOpen]);
|
|
|
|
return (
|
|
<>
|
|
<style>
|
|
{`
|
|
@keyframes ask-ai-open {
|
|
from {
|
|
translate: 100% 0;
|
|
}
|
|
to {
|
|
translate: 0 0;
|
|
}
|
|
}
|
|
@keyframes ask-ai-close {
|
|
from {
|
|
width: var(--ai-chat-width);
|
|
}
|
|
to {
|
|
width: 0px;
|
|
}
|
|
}`}
|
|
</style>
|
|
{actualOpen && (
|
|
<div
|
|
className={cn(
|
|
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
|
|
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
|
|
)}
|
|
onClick={() => setOpen(false)}
|
|
onAnimationEnd={() => {
|
|
if (!open) flushSync(() => setActualOpen(false));
|
|
}}
|
|
/>
|
|
)}
|
|
{actualOpen && (
|
|
<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]',
|
|
open
|
|
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
|
|
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
|
|
)}
|
|
onAnimationEnd={() => {
|
|
if (!open) flushSync(() => setActualOpen(false));
|
|
}}
|
|
>
|
|
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
|
|
<AISearchPanelHeader />
|
|
<AISearchPanelList className="flex-1" />
|
|
<div className="pg-chat-input">
|
|
<AISearchInput />
|
|
<div className="flex items-center gap-1.5 px-2 pb-2 empty:hidden">
|
|
<AISearchInputActions />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
|
|
const chat = useChatContext();
|
|
const messages = chat.messages.filter((msg) => msg.role !== 'system');
|
|
|
|
return (
|
|
<List
|
|
className={cn('py-4 overscroll-contain', className)}
|
|
style={{
|
|
maskImage:
|
|
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
|
|
...style,
|
|
}}
|
|
{...props}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<div
|
|
className="size-full flex flex-col justify-end gap-2 px-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="text-sm text-fd-muted-foreground mb-1">
|
|
Ask anything about Pangolin. The assistant searches and reads these docs to answer.
|
|
</p>
|
|
{suggestions.map((q) => (
|
|
<button
|
|
key={q}
|
|
type="button"
|
|
className="text-start text-sm rounded-xl border bg-fd-card px-3 py-2 text-fd-foreground transition-colors hover:bg-fd-accent"
|
|
onClick={() => sendText(chat.sendMessage, q)}
|
|
>
|
|
{q}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col px-3 gap-4">
|
|
{messages.map((item) => (
|
|
<Message key={item.id} message={item} />
|
|
))}
|
|
{chat.error && (
|
|
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
|
|
<p className="text-xs text-fd-muted-foreground mb-1">Request failed</p>
|
|
<p className="text-sm">{errorText(chat.error)}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</List>
|
|
);
|
|
}
|
|
|
|
/** `/api/chat` returns `{ error }` JSON for config / rate-limit errors */
|
|
function errorText(error: Error) {
|
|
try {
|
|
const parsed = JSON.parse(error.message) as { error?: string };
|
|
if (parsed.error) return parsed.error;
|
|
} catch {
|
|
// not JSON
|
|
}
|
|
return error.message;
|
|
}
|
|
|
|
export function useHotKey() {
|
|
const { open, setOpen } = useAISearchContext();
|
|
|
|
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && open) {
|
|
setOpen(false);
|
|
e.preventDefault();
|
|
}
|
|
|
|
if ((e.key === '/' || e.key === 'i') && (e.metaKey || e.ctrlKey) && !open) {
|
|
setOpen(true);
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('keydown', onKeyPress);
|
|
return () => window.removeEventListener('keydown', onKeyPress);
|
|
}, []);
|
|
}
|
|
|
|
export function useAISearchContext() {
|
|
return use(Context)!;
|
|
}
|
|
|
|
function useChatContext() {
|
|
return use(Context)!.chat;
|
|
}
|