feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import { cn } from "@turbostarter/ui";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useEmbedding } from "../hooks";
import type { Citation } from "@turbostarter/ai/pdf/types";
import type { ReactNode } from "react";
// ============================================================================
// Types
// ============================================================================
export interface CitationPreviewProps {
/** Citation data with excerpt and metadata */
citation: Citation;
/** The citation element to wrap (trigger) */
children: ReactNode;
/** Side to show the tooltip (default: top) */
side?: "top" | "bottom" | "left" | "right";
/** Optional className for content styling */
className?: string;
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Format relevance score as percentage
*/
function formatRelevance(score: number): string {
return `${Math.round(score * 100)}%`;
}
/**
* Truncate excerpt to max length with ellipsis
*/
function truncateExcerpt(excerpt: string, maxLength = 120): string {
if (excerpt.length <= maxLength) return excerpt;
return excerpt.substring(0, maxLength).trim() + "...";
}
// ============================================================================
// Citation Preview Component
// ============================================================================
/**
* Tooltip/popover wrapper showing citation details on hover.
* Displays page number, relevance score, and excerpt preview.
* Fetches embedding content if excerpt is not available.
*
* @example
* ```tsx
* <CitationPreview citation={citation}>
* <Citation citation={citation} />
* </CitationPreview>
* ```
*/
export function CitationPreview({
citation,
children,
side = "top",
className,
}: CitationPreviewProps) {
// Fetch embedding content if excerpt is empty
const { data: embedding, isLoading } = useEmbedding(
citation.excerpt ? null : citation.embeddingId,
);
// Use citation excerpt if available, otherwise use fetched embedding content
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const excerptText = citation.excerpt || embedding?.content || "";
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side={side}
sideOffset={8}
className={cn(
"max-w-xs p-3",
"bg-popover text-popover-foreground",
"border border-border rounded-lg shadow-lg",
className,
)}
>
<div className="space-y-2">
{/* Header with page number and relevance */}
<div className="flex items-center justify-between gap-4">
<span className="text-xs font-medium text-muted-foreground">
Page {citation.pageNumber}
</span>
{citation.relevance > 0 && (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded-full",
citation.relevance >= 0.8
? "bg-green-500/10 text-green-600"
: citation.relevance >= 0.5
? "bg-yellow-500/10 text-yellow-600"
: "bg-muted text-muted-foreground",
)}
>
{formatRelevance(citation.relevance)} match
</span>
)}
</div>
{/* Excerpt preview */}
{isLoading ? (
<Skeleton className="h-12 w-full" />
) : excerptText ? (
<p className="text-sm text-foreground leading-relaxed">
"{truncateExcerpt(excerptText)}"
</p>
) : (
<p className="text-sm text-muted-foreground italic">
No excerpt available
</p>
)}
{/* Click hint */}
<p className="text-xs text-muted-foreground italic">
Click to jump to source
</p>
</div>
</TooltipContent>
</Tooltip>
);
}
export default CitationPreview;

View File

@@ -0,0 +1,86 @@
"use client";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-web/icons";
import { usePdfViewer } from "../context/pdf-viewer-context";
import type { Citation } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
export interface CitationProps {
/** Citation data from parsed AI response */
citation: Citation;
/** Optional className for styling overrides */
className?: string;
}
// ============================================================================
// Citation Component
// ============================================================================
/**
* Clickable inline citation component displayed as [1], [2], etc.
* Clicking navigates to the cited page and highlights the source.
*
* @example
* ```tsx
* <Citation citation={{ index: 1, pageNumber: 5, embeddingId: "abc123", ... }} />
* // Renders: [1] - clickable, navigates to page 5
* ```
*/
export function Citation({ citation, className }: CitationProps) {
const { navigateTo, activeHighlight, clearHighlight } = usePdfViewer();
// Check if this citation is currently active
const isActive = activeHighlight === citation.embeddingId;
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
// Toggle behavior: if already active, deactivate; otherwise activate
if (isActive) {
clearHighlight();
} else {
navigateTo({
page: citation.pageNumber,
embeddingId: citation.embeddingId,
animate: true,
});
}
};
return (
<button
type="button"
onClick={handleClick}
aria-pressed={isActive}
className={cn(
"inline-flex items-center justify-center",
"min-w-[1.5rem] h-5 px-1.5",
"text-xs font-semibold",
"border rounded-full",
"cursor-pointer",
"focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-1",
"transition-all duration-150",
"align-text-top",
// Active vs inactive states
isActive
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 hover:border-primary/30",
className,
)}
aria-label={`Citation ${citation.index}, page ${citation.pageNumber}${isActive ? " (active)" : ""}`}
title={isActive ? "Click to deactivate" : `Go to page ${citation.pageNumber}`}
>
<Icons.FileText className="size-3 mr-0.5" aria-hidden />
<span>{citation.index}</span>
</button>
);
}
export default Citation;

View File

@@ -0,0 +1,8 @@
// PDF Components
// Navigation
export { NavigationControls } from "./navigation-controls";
// Citations
export { Citation, type CitationProps } from "./citation";
export { CitationPreview, type CitationPreviewProps } from "./citation-preview";

View File

@@ -0,0 +1,37 @@
"use client";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { usePdfNavigation } from "../hooks/use-pdf-navigation";
/**
* Back/Forward navigation controls for PDF viewer.
* Compact toolbar-style buttons for history navigation.
*/
export function NavigationControls() {
const { goBack, goForward, canGoBack, canGoForward } = usePdfNavigation();
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
>
<Icons.ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
>
<Icons.ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "../lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
dayjs.extend(relativeTime);
const ChatCard = ({ chat }: { chat: Chat }) => {
const { data: session } = authClient.useSession();
const userId = session?.user.id ?? "";
const queryClient = useQueryClient();
const { mutate: deleteChat, isPending } = useMutation({
...pdf.mutations.chats.delete,
onMutate: async (data) => {
await queryClient.cancelQueries({
queryKey: pdf.queries.chats.user.getAll(userId).queryKey,
});
const previousChats = queryClient.getQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
(old: Chat[]) => old.filter((c) => c.id !== data.id),
);
return { previousChats };
},
onError: (error, _, context) => {
toast.error(error.message);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
context?.previousChats,
);
},
onSettled: async () => {
await queryClient.invalidateQueries(pdf.queries.chats.user.getAll(userId));
},
});
return (
<div className="group bg-card hover:bg-accent/50 relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
<TurboLink
href={pathsConfig.apps.pdf.chat(chat.id)}
className="absolute inset-0 z-0"
/>
<div className="flex items-start justify-between gap-2">
<div className="bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg">
<Icons.FileText className="size-5" />
</div>
<Button
variant="ghost"
size="icon"
className="relative z-10 size-8 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
deleteChat({ id: chat.id });
}}
disabled={isPending}
>
<Icons.Trash className="text-muted-foreground hover:text-destructive size-4" />
</Button>
</div>
<div className="min-w-0">
<p className="truncate font-medium">{chat.name}</p>
<p className="text-muted-foreground text-sm">
{dayjs(chat.createdAt).fromNow()}
</p>
</div>
</div>
);
};
export const RecentChats = () => {
const { t } = useTranslation("ai");
const { data: session } = authClient.useSession();
const userChats = useQuery({
...pdf.queries.chats.user.getAll(session?.user.id ?? ""),
enabled: !!session?.user.id,
});
if (!session?.user.id) {
return null;
}
if (userChats.isLoading) {
return (
<div className="mt-8 w-full max-w-2xl">
<div className="mb-4 flex items-center gap-2">
<Icons.ClockFading className="text-muted-foreground size-4" />
<Skeleton className="h-5 w-24" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-24 rounded-lg" />
</div>
</div>
);
}
const recentChats = userChats.data?.slice(0, 6) ?? [];
if (recentChats.length === 0) {
return null;
}
return (
<div className="mt-8 w-full max-w-2xl">
<div className="text-muted-foreground mb-4 flex items-center gap-2 text-sm font-medium">
<Icons.ClockFading className="size-4" />
<span>{t("pdf.recent")}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{recentChats.map((chat) => (
<ChatCard key={chat.id} chat={chat} />
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface TextSelectionActionProps {
onAskAbout: (text: string) => void;
disabled?: boolean;
}
interface SelectionState {
text: string;
x: number;
y: number;
}
// ============================================================================
// Text Selection Action
// ============================================================================
/**
* Floating action button that appears when text is selected in the PDF viewer.
* Clicking "Ask about this" sends the selected text to the chat composer.
*
* Uses mouseup event instead of selectionchange to avoid excessive re-renders.
*/
export function TextSelectionAction({
onAskAbout,
disabled,
}: TextSelectionActionProps) {
const { t } = useTranslation("ai");
const [selection, setSelection] = useState<SelectionState | null>(null);
const buttonRef = useRef<HTMLDivElement>(null);
// Handle mouse up to check for selection
const handleMouseUp = useCallback(() => {
// Small delay to ensure selection is complete
requestAnimationFrame(() => {
const sel = window.getSelection();
const text = sel?.toString().trim();
if (text && text.length > 3) {
// Only show for meaningful selections (more than 3 chars)
const range = sel?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
if (rect && rect.width > 0) {
setSelection({
text,
x: rect.left + rect.width / 2,
y: rect.top - 10,
});
}
}
});
}, []);
// Clear selection when clicking outside
const handleMouseDown = useCallback((e: MouseEvent) => {
const target = e.target as HTMLElement;
// Don't clear if clicking on the action button itself
if (buttonRef.current?.contains(target)) return;
setSelection(null);
}, []);
// Listen for mouse events
useEffect(() => {
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [handleMouseUp, handleMouseDown]);
const handleAskAbout = useCallback(() => {
if (selection?.text) {
onAskAbout(selection.text);
// Clear selection after asking
window.getSelection()?.removeAllRanges();
setSelection(null);
}
}, [selection, onAskAbout]);
if (!selection) {
return null;
}
return (
<div
ref={buttonRef}
data-selection-action
className={cn(
"fixed z-50 -translate-x-1/2 -translate-y-full",
"animate-in fade-in-0 zoom-in-95 duration-150"
)}
style={{
left: selection.x,
top: selection.y,
}}
>
<Button
size="sm"
variant="default"
className="gap-2 shadow-lg"
onClick={handleAskAbout}
disabled={disabled}
>
<Icons.MessagesSquare className="size-3.5" />
{t("pdf.selection.askAbout")}
</Button>
</div>
);
}
export default TextSelectionAction;

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Composer } from "~/modules/common/ai/composer";
import { useComposer } from "./use-composer";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
interface ChatComposerProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
export const ChatComposer = ({
id,
initialMessages,
}: ChatComposerProps = {}) => {
const { t } = useTranslation(["ai", "common"]);
const { status, input, setInput, sendMessage, stop } = useComposer({
id,
initialMessages,
});
return (
<Composer.Form
onSubmit={(e) => {
e.preventDefault();
void sendMessage({
text: input,
});
setInput("");
}}
>
<Composer.Input className="rounded-xl @sm:rounded-2xl">
<Composer.Textarea
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
maxLength={5_000}
placeholder={t("pdf.composer.placeholder")}
className="pr-11 @[480px]/input:pr-11"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!["submitted", "streaming"].includes(status)) {
void sendMessage({
text: input,
});
setInput("");
}
}
}}
/>
<Button
className="absolute right-3 bottom-3 rounded-full"
disabled={
!input.trim() && !["submitted", "streaming"].includes(status)
}
size="icon"
type="submit"
onClick={(e) => {
if (["submitted", "streaming"].includes(status)) {
e.preventDefault();
return stop();
}
}}
>
{["submitted", "streaming"].includes(status) ? (
<Icons.Pause className="size-4" />
) : (
<Icons.Send className="size-4" />
)}
</Button>
</Composer.Input>
</Composer.Form>
);
};

View File

@@ -0,0 +1,90 @@
import { useChat } from "@ai-sdk/react";
import { Chat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { generateId } from "@turbostarter/shared/utils";
import { api } from "~/lib/api/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { useCredits } from "~/modules/common/layout/credits";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
const chats = new Map<string, Chat<PdfMessage>>();
const getChatInstance = ({
id,
...options
}: ConstructorParameters<typeof Chat<PdfMessage>>[0]) => {
if (!id || !chats.has(id)) {
const chat = new Chat<PdfMessage>({
id,
...options,
});
chats.set(id ?? chat.id, chat);
}
const instance = chats.get(id ?? "");
if (!instance) {
throw new Error(`Chat instance with id ${id} not found!`);
}
return instance;
};
interface UseComposerProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
export const useComposer = ({
id: passedId,
initialMessages,
}: UseComposerProps = {}) => {
const [input, setInput] = useState("");
const { onError } = useAIError();
const { invalidate } = useCredits();
const id = passedId ?? generateId();
const chat = getChatInstance({
id,
transport: new DefaultChatTransport({
api: api.ai.pdf.chats[":id"].messages
.$url({
param: {
id,
},
})
.toString(),
prepareSendMessagesRequest: ({ messages }) => {
const lastMessage = messages.at(-1);
return {
body: {
id: lastMessage?.id,
role: lastMessage?.role,
content: getMessageTextContent(lastMessage),
},
};
},
}),
messages: initialMessages,
onFinish: () => {
void invalidate();
},
onError,
});
const result = useChat({
chat,
});
return {
...result,
input,
setInput,
};
};

View File

@@ -0,0 +1,6 @@
export {
PdfViewerProvider,
usePdfViewer,
useCanGoBack,
useCanGoForward,
} from "./pdf-viewer-context";

View File

@@ -0,0 +1,242 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import type {
NavigationEntry,
PdfViewerActions,
PdfViewerState,
PreciseCitation,
TextHighlight,
} from "@turbostarter/ai/pdf/types";
import type { ReactNode } from "react";
// ============================================================================
// Context Types
// ============================================================================
/** Navigation request to be consumed by PageSync */
export interface PendingNavigation {
page: number;
embeddingId?: string;
animate?: boolean;
}
interface PdfViewerContextValue extends PdfViewerState, PdfViewerActions {
/** Pending navigation request (consumed by PageSync, then cleared) */
pendingNavigation: PendingNavigation | null;
/** Clear the pending navigation after it's been processed */
clearPendingNavigation: () => void;
/** Text highlights from highlightText tool calls */
textHighlights: TextHighlight[];
/** Add a citation from highlightText tool call */
addTextHighlight: (citation: PreciseCitation) => void;
/** Update highlight rects after text search resolves */
updateTextHighlightRects: (id: string, rects: DOMRect[], found: boolean) => void;
/** Clear all text highlights (e.g., on new message) */
clearTextHighlights: () => void;
}
// ============================================================================
// Context
// ============================================================================
const PdfViewerContext = createContext<PdfViewerContextValue | null>(null);
// ============================================================================
// Provider
// ============================================================================
interface PdfViewerProviderProps {
children: ReactNode;
/** Initial page to display */
initialPage?: number;
}
export function PdfViewerProvider({
children,
initialPage = 1,
}: PdfViewerProviderProps) {
// State
const [currentPage, setCurrentPage] = useState(initialPage);
const [zoomLevel, _setZoomLevel] = useState(1);
const [scrollPosition, _setScrollPosition] = useState(0);
const [activeHighlight, setActiveHighlight] = useState<string | null>(null);
const [history, setHistory] = useState<NavigationEntry[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [pendingNavigation, setPendingNavigation] =
useState<PendingNavigation | null>(null);
const [textHighlights, setTextHighlights] = useState<TextHighlight[]>([]);
// Actions
const navigateTo = useCallback(
(options: { page: number; embeddingId?: string; animate?: boolean }) => {
const { page, embeddingId, animate = true } = options;
// Add to history
const entry: NavigationEntry = {
page,
embeddingId,
timestamp: Date.now(),
};
setHistory((prev) => {
// If we're in the middle of history, truncate forward entries
const newHistory =
historyIndex >= 0 ? prev.slice(0, historyIndex + 1) : prev;
return [...newHistory, entry];
});
setHistoryIndex((prev) => prev + 1);
// Set highlight for HighlightLayer
setActiveHighlight(embeddingId ?? null);
// Set pending navigation for PageSync to consume
// PageSync will call lector's jumpToPage and update currentPage
setPendingNavigation({ page, embeddingId, animate });
},
[historyIndex],
);
const clearPendingNavigation = useCallback(() => {
setPendingNavigation(null);
}, []);
const goBack = useCallback(() => {
if (historyIndex <= 0) return;
const prevIndex = historyIndex - 1;
const entry = history[prevIndex];
if (!entry) return;
setHistoryIndex(prevIndex);
setCurrentPage(entry.page);
setActiveHighlight(entry.embeddingId ?? null);
}, [history, historyIndex]);
const goForward = useCallback(() => {
if (historyIndex >= history.length - 1) return;
const nextIndex = historyIndex + 1;
const entry = history[nextIndex];
if (!entry) return;
setHistoryIndex(nextIndex);
setCurrentPage(entry.page);
setActiveHighlight(entry.embeddingId ?? null);
}, [history, historyIndex]);
const clearHighlight = useCallback(() => {
setActiveHighlight(null);
}, []);
// Text highlight actions (for highlightText tool)
const addTextHighlight = useCallback((citation: PreciseCitation) => {
setTextHighlights((prev) => [
...prev,
{
id: citation.citationId,
text: citation.text,
page: citation.page,
rects: [], // Populated when page renders
found: false,
},
]);
}, []);
const updateTextHighlightRects = useCallback(
(id: string, rects: DOMRect[], found: boolean) => {
setTextHighlights((prev) =>
prev.map((h) => (h.id === id ? { ...h, rects, found } : h)),
);
},
[],
);
const clearTextHighlights = useCallback(() => {
setTextHighlights([]);
}, []);
// Memoized context value
const value = useMemo<PdfViewerContextValue>(
() => ({
// State
currentPage,
zoomLevel,
scrollPosition,
activeHighlight,
history,
historyIndex,
pendingNavigation,
textHighlights,
// Actions
navigateTo,
goBack,
goForward,
clearHighlight,
clearPendingNavigation,
setCurrentPage,
addTextHighlight,
updateTextHighlightRects,
clearTextHighlights,
}),
[
currentPage,
zoomLevel,
scrollPosition,
activeHighlight,
history,
historyIndex,
pendingNavigation,
textHighlights,
navigateTo,
goBack,
goForward,
clearHighlight,
clearPendingNavigation,
addTextHighlight,
updateTextHighlightRects,
clearTextHighlights,
],
);
return (
<PdfViewerContext.Provider value={value}>
{children}
</PdfViewerContext.Provider>
);
}
// ============================================================================
// Hook
// ============================================================================
export function usePdfViewer(): PdfViewerContextValue {
const context = useContext(PdfViewerContext);
if (!context) {
throw new Error("usePdfViewer must be used within a PdfViewerProvider");
}
return context;
}
/**
* Check if we can go back in navigation history
*/
export function useCanGoBack(): boolean {
const { historyIndex } = usePdfViewer();
return historyIndex > 0;
}
/**
* Check if we can go forward in navigation history
*/
export function useCanGoForward(): boolean {
const { history, historyIndex } = usePdfViewer();
return historyIndex < history.length - 1;
}

View File

@@ -0,0 +1,25 @@
import { useTranslation } from "@turbostarter/i18n";
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
interface ChatActionsProps {
onSelect: () => void;
}
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
const { t } = useTranslation(["common", "ai"]);
return (
<CommandGroup heading={t("actions")}>
<CommandItem asChild>
<TurboLink href={pathsConfig.apps.pdf.index} onClick={onSelect}>
<Icons.FileUpIcon />
<span>{t("pdf.new")}</span>
</TurboLink>
</CommandItem>
</CommandGroup>
);
};

View File

@@ -0,0 +1,96 @@
"use client";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import { useState, useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandList,
} from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { ChatActions } from "./actions";
import { ChatHistoryList } from "./list";
dayjs.extend(duration);
dayjs.extend(relativeTime);
interface CommandMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
const { t } = useTranslation("ai");
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
showCloseButton={false}
>
<CommandInput placeholder={t("pdf.command.search")} />
<CommandList className="h-[420px]">
<CommandEmpty className="py-10">{t("pdf.command.empty")}</CommandEmpty>
<ChatActions onSelect={() => onOpenChange(false)} />
<ChatHistoryList onSelect={() => onOpenChange(false)} />
</CommandList>
</CommandDialog>
);
};
export const ChatHistory = () => {
const { t } = useTranslation("common");
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={() => setIsOpen(true)}
>
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{t("history")}</span>
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className=""></span>K
</kbd>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
</>
);
};

View File

@@ -0,0 +1,57 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@turbostarter/i18n";
import { useDateGroups } from "@turbostarter/shared/hooks";
import { CommandGroup } from "@turbostarter/ui-web/command";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { authClient } from "~/lib/auth/client";
import { pdf } from "../../lib/api";
import { ChatHistoryListItem } from "./item";
interface ChatHistoryListProps {
onSelect: () => void;
}
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
const { t } = useTranslation("common");
const { data: session } = authClient.useSession();
const userChats = useQuery(
pdf.queries.chats.user.getAll(session?.user.id ?? ""),
);
const groups = useDateGroups(userChats.data ?? []);
if (userChats.isLoading) {
return (
<CommandGroup heading={t("history")} className="w-full">
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
<Skeleton className="h-11 w-1/2 rounded-xl" />
</CommandGroup>
);
}
return (
<>
{groups.map(
(group) =>
group.items.length > 0 && (
<CommandGroup heading={group.label} key={group.label}>
{group.items.map((chat) => (
<ChatHistoryListItem
key={chat.id}
chat={chat}
onSelect={onSelect}
/>
))}
</CommandGroup>
),
)}
</>
);
};

View File

@@ -0,0 +1,166 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import { CommandItem } from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "~/modules/pdf/lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
interface ChatHistoryListItemProps {
readonly chat: Chat;
readonly onSelect: () => void;
}
export const ChatHistoryListItem = ({
chat,
onSelect,
}: ChatHistoryListItemProps) => {
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
return (
<CommandItem
key={chat.id}
value={`${chat.id}-${chat.name}`}
asChild
onSelect={() => {
router.push(pathsConfig.apps.pdf.chat(chat.id));
onSelect();
}}
className="group"
>
<div>
<TurboLink
href={pathsConfig.apps.pdf.chat(chat.id)}
onClick={onSelect}
className="flex min-w-0 grow items-center justify-start gap-3"
>
<Icons.MessagesSquare />
<span className="truncate">{chat.name}</span>
{pathname.includes(chat.id) && (
<Badge variant="outline">{t("current")}</Badge>
)}
</TurboLink>
<Controls chat={chat} />
</div>
</CommandItem>
);
};
const Controls = ({ chat }: { chat: Chat }) => {
const { data: session } = authClient.useSession();
const userId = session?.user.id ?? "";
const { t } = useTranslation("common");
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const { mutate } = useMutation({
...pdf.mutations.chats.delete,
onMutate: async (data) => {
await queryClient.cancelQueries({
queryKey: pdf.queries.chats.user.getAll(userId).queryKey,
});
const previousChats = queryClient.getQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
);
if (pathname.includes(chat.id)) {
router.push(pathsConfig.apps.pdf.index);
}
return { previousChats };
},
onError: (error, _, context) => {
toast.error(error.message);
queryClient.setQueryData(
pdf.queries.chats.user.getAll(userId).queryKey,
context?.previousChats,
);
},
onSettled: async () => {
await queryClient.invalidateQueries(
pdf.queries.chats.user.getAll(userId),
);
},
});
return (
<>
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
{dayjs(chat.createdAt).fromNow()}
</span>
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
window.open(pathsConfig.apps.pdf.chat(chat.id), "_blank");
}}
>
<Icons.ExternalLink className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("newTab")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
onClick={(e) => {
e.stopPropagation();
mutate({ id: chat.id });
}}
>
<Icons.Trash className="text-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
);
};

View File

@@ -0,0 +1,7 @@
// PDF Hooks
export { usePdfViewer, useCanGoBack, useCanGoForward } from "../context";
export { usePdfNavigation } from "./use-pdf-navigation";
export { useEmbedding } from "./use-embedding";
export { useCitationUnit } from "./use-citation-unit";
export type { CitationUnitDetail, BoundingBox } from "./use-citation-unit";

View File

@@ -0,0 +1,73 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "~/lib/api/client";
// ============================================================================
// Types
// ============================================================================
/**
* Bounding box for pixel-perfect highlighting
*/
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
}
/**
* Citation unit with precise location for highlighting (WF-0028)
*/
export interface CitationUnitDetail {
id: string;
content: string;
pageNumber: number;
paragraphIndex: number;
charStart: number;
charEnd: number;
bbox: BoundingBox | null;
sectionTitle: string | null;
unitType: string;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Fetch citation unit details by ID for bounding box-based highlighting
*
* Falls back to legacy embedding endpoint if citation unit not found
*/
export function useCitationUnit(unitId: string | null) {
return useQuery({
queryKey: ["pdf", "citation-unit", unitId],
queryFn: async (): Promise<CitationUnitDetail | null> => {
if (!unitId) return null;
// Try citation unit endpoint first (WF-0028 dual-resolution)
const response = await api.ai.pdf.search["citation-units"].single[":id"].$get({
param: { id: unitId },
});
if (response.ok) {
const result = await response.json();
return (result as { data: CitationUnitDetail }).data;
}
// If not found in citation units, this might be a legacy embedding ID
// Return null - the highlight layer will fall back to word overlap
if (response.status === 404) {
return null;
}
throw new Error("Failed to fetch citation unit");
},
enabled: Boolean(unitId),
staleTime: Infinity, // Citation units don't change
gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes
});
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "~/lib/api/client";
// ============================================================================
// Types
// ============================================================================
export interface EmbeddingDetail {
id: string;
content: string;
pageNumber: number;
charStart?: number;
charEnd?: number;
sectionTitle?: string;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Fetch embedding details by ID for citation highlighting
*/
export function useEmbedding(embeddingId: string | null) {
return useQuery({
queryKey: ["pdf", "embedding", embeddingId],
queryFn: async (): Promise<EmbeddingDetail | null> => {
if (!embeddingId) return null;
const response = await api.ai.pdf.embeddings[":id"].$get({
param: { id: embeddingId },
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error("Failed to fetch embedding");
}
return response.json() as Promise<EmbeddingDetail>;
},
enabled: Boolean(embeddingId),
staleTime: Infinity, // Embeddings don't change
gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes
});
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useCanGoBack, useCanGoForward, usePdfViewer } from "../context";
/**
* Convenience hook for PDF navigation controls.
* Combines navigation state and actions in one place.
*/
export function usePdfNavigation() {
const { goBack, goForward, navigateTo, history, historyIndex } =
usePdfViewer();
const canGoBack = useCanGoBack();
const canGoForward = useCanGoForward();
return {
// Actions
goBack,
goForward,
navigateTo,
// State
canGoBack,
canGoForward,
historyLength: history.length,
currentIndex: historyIndex,
};
}

View File

@@ -0,0 +1,29 @@
// PDF Module
// Exports for external use
// Context & Hooks
export {
PdfViewerProvider,
usePdfViewer,
useCanGoBack,
useCanGoForward,
} from "./context";
export { usePdfNavigation } from "./hooks";
// Layout Components
export { PdfLayout } from "./layout/layout";
export { PdfPreview } from "./layout/preview";
// Thread Components
export { Chat as PdfChat } from "./thread";
export { ChatComposer as PdfComposer } from "./composer";
export { CitationMarkdown } from "./thread/citation-markdown";
// Navigation & Citations
export {
NavigationControls,
Citation,
CitationPreview,
type CitationProps,
type CitationPreviewProps,
} from "./components";

View File

@@ -0,0 +1,209 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { useBreakpoint } from "@turbostarter/ui-web";
import { Button } from "@turbostarter/ui-web/button";
import { Drawer, DrawerContent } from "@turbostarter/ui-web/drawer";
import { Icons } from "@turbostarter/ui-web/icons";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@turbostarter/ui-web/resizable";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { Header } from "~/modules/common/layout/header";
import { ThemeSwitcher } from "~/modules/common/theme";
import { PdfViewerProvider } from "../context";
import { ChatHistory } from "../history";
import { pdf } from "../lib/api";
import { PdfPreview } from "./preview";
const Trigger = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const { t } = useTranslation("ai");
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group relative"
onClick={() => onOpenChange(!open)}
>
<Icons.FileText className="text-muted-foreground group-hover:text-foreground size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" sideOffset={5}>
<span>{t("pdf.preview.toggle")}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const MobileDocumentPreview = ({
id,
open,
onOpenChange,
}: {
id: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="border-b-none fixed right-0 bottom-0 left-0 mx-[-1px] flex max-h-[97%] flex-col gap-3 rounded-t-[10px] border px-3 pb-3">
<Documents id={id} />
</DrawerContent>
</Drawer>
);
};
const ProcessingIndicator = () => {
const { t } = useTranslation("ai");
return (
<div className="bg-background/95 absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 backdrop-blur-sm">
<div className="flex items-center gap-3">
<Icons.Loader2 className="text-primary size-6 animate-spin" />
<span className="text-muted-foreground text-sm font-medium">
{t("pdf.processing.indexing")}
</span>
</div>
<p className="text-muted-foreground max-w-xs text-center text-xs">
{t("pdf.processing.indexingDescription")}
</p>
</div>
);
};
const Documents = ({ id }: { id: string }) => {
const { t } = useTranslation("ai");
const documents = useQuery(pdf.queries.chats.documents.getAll(id));
const document = documents.data?.[0];
// Poll for document processing status
const status = useQuery({
...pdf.queries.chats.documents.getStatus(document?.id ?? ""),
enabled: !!document?.id,
refetchInterval: (query) => {
const data = query.state.data;
// Stop polling once ready, failed, or error
if (!data || "error" in data) {
return false;
}
if (data.processingStatus === "ready" || data.processingStatus === "failed") {
return false;
}
return 2000; // Poll every 2 seconds while processing
},
});
const url = useQuery({
...pdf.queries.chats.documents.getUrl(document?.path ?? ""),
enabled: !!document,
staleTime: 1000 * 60 * 60,
});
if (documents.isLoading || url.isLoading) {
return <Skeleton className="h-full w-full" />;
}
if (!url.data?.url) {
return (
<div className="text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-4 rounded-lg border p-6">
<Icons.FileX className="size-12" />
<p className="max-w-sm text-center">{t("pdf.preview.noDocuments")}</p>
</div>
);
}
// Check if still processing (not ready, not failed, not error)
const statusData = status.data && !("error" in status.data) ? status.data : null;
const isProcessing = statusData?.processingStatus !== "ready" &&
statusData?.processingStatus !== "failed";
return (
<div className="relative h-full w-full">
<PdfPreview url={url.data.url} />
{isProcessing && <ProcessingIndicator />}
</div>
);
};
export const PdfLayout = ({
children,
id,
}: {
children: React.ReactNode;
id: string;
}) => {
const [open, setOpen] = useState(true);
const isDesktop = useBreakpoint("lg");
if (isDesktop) {
return (
<PdfViewerProvider>
<ResizablePanelGroup orientation="horizontal" className="h-full w-full">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="relative flex h-full flex-col">
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
<Trigger open={open} onOpenChange={setOpen} />
</div>
</Header>
{children}
</div>
</ResizablePanel>
{open && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={25}>
<Documents id={id} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</PdfViewerProvider>
);
}
return (
<PdfViewerProvider>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<Header>
<div className="flex items-center gap-1">
<ChatHistory />
<ThemeSwitcher />
<Trigger open={open} onOpenChange={setOpen} />
</div>
</Header>
{children}
<MobileDocumentPreview open={open} onOpenChange={setOpen} id={id} />
</div>
</PdfViewerProvider>
);
};

View File

@@ -0,0 +1,81 @@
import { usePdf } from "@anaralabs/lector";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
interface DocumentMenuProps {
readonly documentUrl: string;
}
export const DocumentMenu = ({ documentUrl }: DocumentMenuProps) => {
const { t } = useTranslation("common");
const [isDownloading, setIsDownloading] = useState(false);
const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy);
const handleDownload = async () => {
if (isDownloading) return;
try {
setIsDownloading(true);
const pdfData = await pdfDocumentProxy.getData();
const blob = new Blob([pdfData as BlobPart], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename = documentUrl.split("/").pop() ?? "document.pdf";
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
toast.error(t("error.general"));
} finally {
setIsDownloading(false);
}
};
const handleOpenClick = () => {
window.open(documentUrl, "_blank");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onClick={handleDownload}
disabled={isDownloading || !pdfDocumentProxy}
className="gap-2"
>
<Icons.DownloadCloud className="size-4" />
{isDownloading ? t("downloading") : t("download")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleOpenClick} className="gap-2">
<Icons.Link className="size-4" /> {t("open")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default DocumentMenu;

View File

@@ -0,0 +1,551 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
"use client";
import { memo, useCallback, useEffect, useRef } from "react";
import { usePdfViewer } from "../../context";
import { useEmbedding } from "../../hooks";
import { useCitationUnit } from "../../hooks/use-citation-unit";
import type { BoundingBox } from "../../hooks/use-citation-unit";
// ============================================================================
// Constants
// ============================================================================
/** Duration in ms before auto-clearing the highlight */
const HIGHLIGHT_DURATION_MS = 5000;
/** Minimum word match percentage to consider a span relevant (legacy fallback) */
const MIN_MATCH_PERCENTAGE = 0.25;
/** CSS class for primary highlight (exact citation - violet) */
const HIGHLIGHT_PRIMARY_CLASS = "pdf-citation-primary";
/** CSS class for secondary highlight (context - yellow) */
const HIGHLIGHT_SECONDARY_CLASS = "pdf-citation-secondary";
/** Legacy class name for backward compatibility */
const HIGHLIGHT_CLASS = "pdf-citation-highlight";
/** Data attribute to mark highlighted spans */
const HIGHLIGHT_ATTR = "data-citation-highlight";
// ============================================================================
// Styles
// ============================================================================
/** Injected styles for text span highlighting (two-level: primary + secondary) */
const HIGHLIGHT_STYLES = `
/* Secondary highlight - yellow/amber for context */
.${HIGHLIGHT_SECONDARY_CLASS} {
background-color: rgba(250, 204, 21, 0.25) !important;
border-radius: 2px;
transition: background-color 300ms ease-in-out;
}
/* Primary highlight - violet for exact citation (overrides secondary) */
.${HIGHLIGHT_PRIMARY_CLASS} {
background-color: rgba(139, 92, 246, 0.4) !important;
border-radius: 2px;
box-shadow: 0 0 6px rgba(139, 92, 246, 0.5);
transition: background-color 300ms ease-in-out;
}
/* Legacy highlight class (backward compatibility) */
.${HIGHLIGHT_CLASS} {
background-color: rgba(250, 204, 21, 0.4) !important;
border-radius: 2px;
box-shadow: 0 0 4px rgba(250, 204, 21, 0.6);
transition: background-color 300ms ease-in-out;
}
`;
// ============================================================================
// Utilities - Legacy Word Overlap Matching
// ============================================================================
/**
* Inject highlight styles into document head (once)
*/
function ensureStylesInjected(): void {
if (typeof document === "undefined") return;
if (document.getElementById("pdf-highlight-styles")) return;
const style = document.createElement("style");
style.id = "pdf-highlight-styles";
style.textContent = HIGHLIGHT_STYLES;
document.head.appendChild(style);
}
/**
* Normalize text for comparison - removes extra whitespace, lowercases
*/
function normalizeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
/**
* Get significant words from text (words with 3+ characters)
*/
function getSignificantWords(text: string): Set<string> {
const normalized = normalizeText(text);
const words = normalized.split(/\s+/).filter((w) => w.length >= 3);
return new Set(words);
}
/**
* Calculate word overlap percentage between two texts
*/
function calculateWordOverlap(text1: string, text2: string): number {
const words1 = getSignificantWords(text1);
const words2 = getSignificantWords(text2);
if (words1.size === 0 || words2.size === 0) return 0;
let matchCount = 0;
for (const word of words1) {
if (words2.has(word)) matchCount++;
}
return matchCount / Math.min(words1.size, words2.size);
}
/**
* Find text layer spans that match the embedding content and apply highlights
*/
function applyHighlightsToSpans(
container: Element,
embeddingContent: string,
): number {
// Find the TextLayer - it has class "textLayer" from pdfjs
const textLayers = container.querySelectorAll(".textLayer");
if (textLayers.length === 0) {
console.debug("[HighlightLayer] No TextLayer found");
return 0;
}
let highlightCount = 0;
// Check each text layer
for (const textLayer of textLayers) {
const spans = textLayer.querySelectorAll("span");
// For each span, check if it contains significant words from the embedding
for (const span of spans) {
const spanText = span.textContent ?? "";
if (spanText.trim().length < 3) continue;
const overlap = calculateWordOverlap(spanText, embeddingContent);
if (overlap >= MIN_MATCH_PERCENTAGE) {
span.classList.add(HIGHLIGHT_CLASS);
span.setAttribute(HIGHLIGHT_ATTR, "true");
highlightCount++;
}
}
}
// If no individual spans match, try grouping consecutive spans
if (highlightCount === 0) {
for (const textLayer of textLayers) {
const spans = Array.from(textLayer.querySelectorAll("span"));
const combinedText = spans.map((s) => s.textContent ?? "").join(" ");
// Check if the combined text contains significant content from embedding
const overlap = calculateWordOverlap(combinedText, embeddingContent);
if (overlap >= MIN_MATCH_PERCENTAGE) {
// Find contiguous groups that match
for (let i = 0; i < spans.length; i++) {
let groupText = "";
for (let j = i; j < Math.min(i + 10, spans.length); j++) {
groupText += " " + (spans[j]?.textContent ?? "");
const groupOverlap = calculateWordOverlap(
groupText,
embeddingContent,
);
if (groupOverlap >= MIN_MATCH_PERCENTAGE) {
// Highlight all spans in this group
for (let k = i; k <= j; k++) {
const span = spans[k];
if (span) {
span.classList.add(HIGHLIGHT_CLASS);
span.setAttribute(HIGHLIGHT_ATTR, "true");
highlightCount++;
}
}
break;
}
}
if (highlightCount > 0) break;
}
}
}
}
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans (legacy)`);
return highlightCount;
}
/**
* Remove all highlights from the document (clears all highlight classes)
*/
function clearAllHighlights(container: Element | null): void {
if (!container) return;
const highlighted = container.querySelectorAll(`[${HIGHLIGHT_ATTR}]`);
for (const el of highlighted) {
el.classList.remove(HIGHLIGHT_PRIMARY_CLASS);
el.classList.remove(HIGHLIGHT_SECONDARY_CLASS);
el.classList.remove(HIGHLIGHT_CLASS);
el.removeAttribute(HIGHLIGHT_ATTR);
}
}
// ============================================================================
// Utilities - Bounding Box to Text Span Highlighting
// ============================================================================
/**
* Parse percentage value from CSS style string (e.g., "left: 31.1%" -> 31.1)
*/
function parsePercentage(style: string, property: string): number | null {
const regex = new RegExp(`${property}:\\s*([\\d.]+)%`);
const match = style.match(regex);
return match?.[1] ? parseFloat(match[1]) : null;
}
/** Margin settings for highlight levels */
interface HighlightMargins {
horizontal: number;
vertical: number;
}
/** Tight margins for primary highlight (exact citation) */
const PRIMARY_MARGINS: HighlightMargins = { horizontal: 1, vertical: 0.5 };
/** Wider margins for secondary highlight (context) */
const SECONDARY_MARGINS: HighlightMargins = { horizontal: 5, vertical: 3 };
/**
* Check if a span's position overlaps with the bbox region
* Both bbox (0-1 normalized) and span positions (0-100 percentage) need alignment
*/
function spanOverlapsBbox(
span: HTMLElement,
bbox: BoundingBox,
margins: HighlightMargins = SECONDARY_MARGINS,
): boolean {
const style = span.getAttribute("style") ?? "";
// Parse span position from inline style (percentage-based)
const spanLeft = parsePercentage(style, "left");
const spanTop = parsePercentage(style, "top");
if (spanLeft === null || spanTop === null) {
return false;
}
// Convert bbox normalized coords (0-1) to percentage (0-100) for comparison
const bboxLeft = bbox.x * 100;
const bboxTop = bbox.y * 100;
const bboxRight = (bbox.x + bbox.width) * 100;
const bboxBottom = (bbox.y + bbox.height) * 100;
const spanInHorizontalRange = spanLeft >= (bboxLeft - margins.horizontal) &&
spanLeft <= (bboxRight + margins.horizontal);
const spanInVerticalRange = spanTop >= (bboxTop - margins.vertical) &&
spanTop <= (bboxBottom + margins.vertical);
return spanInHorizontalRange && spanInVerticalRange;
}
/** Highlight level for two-tier highlighting */
type HighlightLevel = "primary" | "secondary";
/**
* Find and highlight text layer spans that fall within a bounding box
* Supports two-level highlighting: primary (exact citation) and secondary (context)
*/
function applyBboxHighlightsToSpans(
container: Element,
bbox: BoundingBox,
pageNumber: number,
level: HighlightLevel = "primary",
): number {
// Find the specific page's text layer
const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`);
const textLayer = pageElement?.querySelector(".textLayer") ??
container.querySelector(".textLayer");
if (!textLayer) {
console.debug("[HighlightLayer] No TextLayer found for bbox highlight");
return 0;
}
const spans = textLayer.querySelectorAll("span");
let highlightCount = 0;
// Select margins and class based on level
const margins = level === "primary" ? PRIMARY_MARGINS : SECONDARY_MARGINS;
const highlightClass = level === "primary" ? HIGHLIGHT_PRIMARY_CLASS : HIGHLIGHT_SECONDARY_CLASS;
for (const span of spans) {
const spanText = span.textContent ?? "";
if (spanText.trim().length < 1) continue;
if (spanOverlapsBbox(span as HTMLElement, bbox, margins)) {
span.classList.add(highlightClass);
span.setAttribute(HIGHLIGHT_ATTR, level);
highlightCount++;
}
}
console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans via bbox (${level})`);
return highlightCount;
}
// ============================================================================
// Main Component
// ============================================================================
/**
* HighlightLayer - Applies CSS highlights to PDF TextLayer spans based on citations
*
* All highlighting is done by applying CSS classes directly to the PDF.js TextLayer
* span elements, ensuring highlights scroll naturally with the document.
*
* Supports two highlight detection modes:
* 1. Bounding Box (WF-0028): Uses bbox coordinates to find spans within the region
* 2. Word Overlap (legacy): Falls back to text matching when bbox unavailable
*
* When `activeHighlight` is set in the PdfViewerContext, this component:
* 1. Fetches the citation unit data (or legacy embedding)
* 2. If bbox available: Finds TextLayer spans within the bounding box region
* 3. If no bbox: Searches TextLayer for spans with matching text content
* 4. Applies CSS classes to matching spans for highlighting
*
* The highlight auto-clears after 5 seconds.
*/
export const HighlightLayer = memo(function HighlightLayer() {
const { activeHighlight, clearHighlight } = usePdfViewer();
// Try citation unit first (WF-0028)
const { data: citationUnit, isLoading: citationLoading } = useCitationUnit(activeHighlight);
// Fall back to legacy embedding if citation unit not found
const shouldFetchEmbedding = Boolean(activeHighlight) && !citationLoading && !citationUnit;
const { data: embedding } = useEmbedding(shouldFetchEmbedding ? activeHighlight : null);
const containerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isApplyingHighlightsRef = useRef(false);
const lastHighlightIdRef = useRef<string | null>(null);
// Determine which mode to use
const hasBbox = citationUnit?.bbox != null;
const fallbackContent = citationUnit?.content ?? embedding?.content;
// Debug logging for highlight mode selection
useEffect(() => {
if (activeHighlight) {
console.debug("[HighlightLayer] Active highlight:", activeHighlight, {
citationLoading,
hasCitationUnit: Boolean(citationUnit),
hasBbox,
hasEmbedding: Boolean(embedding),
fallbackContentPreview: fallbackContent?.slice(0, 50),
});
}
}, [activeHighlight, citationLoading, citationUnit, hasBbox, embedding, fallbackContent]);
// Ensure styles are injected (used by both bbox and legacy modes)
useEffect(() => {
ensureStylesInjected();
}, []);
// Apply bbox-based highlights (CSS on text spans within bounding box)
// Two-level highlighting: secondary (yellow, context) then primary (violet, exact citation)
// Note: Clearing is handled by the main effect, not here
const applyBboxHighlights = useCallback(() => {
const container = containerRef.current?.parentElement;
if (!container || !citationUnit?.bbox) {
return;
}
// First: Apply secondary highlights (yellow) with wider margins for context
const secondaryCount = applyBboxHighlightsToSpans(
container,
citationUnit.bbox,
citationUnit.pageNumber,
"secondary",
);
// Second: Apply primary highlights (violet) with tight margins for exact citation
// This overlays secondary highlights on matching spans (CSS priority handles override)
const primaryCount = applyBboxHighlightsToSpans(
container,
citationUnit.bbox,
citationUnit.pageNumber,
"primary",
);
console.debug(
`[HighlightLayer] Two-level highlights: ${primaryCount} primary (violet), ${secondaryCount} secondary (yellow)`,
);
}, [citationUnit]);
// Apply legacy highlights to matching text (word overlap)
// Note: Clearing is handled by the main effect, not here
const applyLegacyHighlights = useCallback(() => {
const container = containerRef.current?.parentElement;
if (!container || !fallbackContent) {
console.debug("[HighlightLayer] Legacy mode: no container or content", {
hasContainer: Boolean(container),
contentPreview: fallbackContent?.slice(0, 100),
});
return;
}
console.debug("[HighlightLayer] Applying legacy highlights with content:", fallbackContent.slice(0, 100) + "...");
// Apply new highlights using word overlap matching
const count = applyHighlightsToSpans(container, fallbackContent);
if (count === 0) {
console.warn("[HighlightLayer] No spans matched for legacy highlight. TextLayers found:", container.querySelectorAll(".textLayer").length);
}
}, [fallbackContent]);
// Apply highlights when data changes (unified for both modes)
useEffect(() => {
const container = containerRef.current?.parentElement;
if (!activeHighlight) {
// Only clear if we previously had a highlight
if (lastHighlightIdRef.current !== null) {
clearAllHighlights(container ?? null);
lastHighlightIdRef.current = null;
}
return;
}
// Choose highlight method based on available data
const applyHighlights = hasBbox ? applyBboxHighlights : applyLegacyHighlights;
// Wait for condition to be ready
if (hasBbox && !citationUnit?.bbox) return;
if (!hasBbox && !fallbackContent) return;
if (!container) return;
// Check if this is a NEW highlight (different ID) - only then clear old highlights
const isNewHighlight = lastHighlightIdRef.current !== activeHighlight;
if (isNewHighlight) {
clearAllHighlights(container);
lastHighlightIdRef.current = activeHighlight;
}
// Use ref-based flag to prevent MutationObserver re-triggering across effect runs
const safeApplyHighlights = () => {
if (isApplyingHighlightsRef.current) return;
isApplyingHighlightsRef.current = true;
try {
// Don't clear again - just apply (clearing already done above for new highlights)
applyHighlights();
} finally {
// Reset flag after a brief delay to allow DOM to settle
setTimeout(() => {
isApplyingHighlightsRef.current = false;
}, 100);
}
};
// Give the TextLayer time to render after page navigation
const initialTimeout = setTimeout(safeApplyHighlights, 100);
// Observe DOM changes in case TextLayer loads later (e.g., lazy loading pages)
// Only re-apply when NEW TextLayer content is added, not when we modify highlight classes
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new MutationObserver((mutations) => {
// Check if any mutation added new TextLayer spans (not just class changes)
const hasNewTextContent = mutations.some((mutation) => {
// Only care about added nodes
if (mutation.type !== "childList" || mutation.addedNodes.length === 0) {
return false;
}
// Check if added nodes contain TextLayer or span elements
return Array.from(mutation.addedNodes).some((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as Element;
return el.classList.contains("textLayer") ||
el.querySelector(".textLayer") !== null ||
(el.tagName === "SPAN" && el.closest(".textLayer"));
});
});
if (!hasNewTextContent) return;
// Debounce to handle multiple rapid mutations
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(safeApplyHighlights, 150);
});
observer.observe(container, {
childList: true,
subtree: true,
});
return () => {
clearTimeout(initialTimeout);
if (debounceTimeout) clearTimeout(debounceTimeout);
observer.disconnect();
};
}, [activeHighlight, hasBbox, citationUnit, fallbackContent, applyBboxHighlights, applyLegacyHighlights]);
// Auto-clear highlight after duration
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (activeHighlight) {
timeoutRef.current = setTimeout(() => {
clearAllHighlights(containerRef.current?.parentElement ?? null);
clearHighlight();
timeoutRef.current = null;
}, HIGHLIGHT_DURATION_MS);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [activeHighlight, clearHighlight]);
// Cleanup highlights on unmount
useEffect(() => {
const container = containerRef.current;
return () => {
clearAllHighlights(container?.parentElement ?? null);
};
}, []);
// This component no longer renders any visible elements
// All highlighting is done via CSS classes on TextLayer spans
return (
<div
ref={containerRef}
data-highlight-layer
style={{ display: "none" }}
aria-hidden="true"
/>
);
});
HighlightLayer.displayName = "HighlightLayer";

View File

@@ -0,0 +1,93 @@
"use client";
import {
CanvasLayer,
Page,
Pages,
Root,
TextLayer,
usePdf,
usePdfJump,
} from "@anaralabs/lector";
import { GlobalWorkerOptions } from "pdfjs-dist";
import React, { memo, useEffect } from "react";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { usePdfViewer } from "../../context";
import { DocumentMenu } from "./document-menu";
import { HighlightLayer } from "./highlight-layer";
import { PageNavigation } from "./page-navigation";
import "./pdf-viewer.css";
import { TextHighlightLayer } from "./text-highlight-layer";
import { ZoomMenu } from "./zoom-menu";
// Import extracted PDF text layer styles (avoids problematic pdfjs-dist CSS with relative image imports)
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).toString();
interface PdfPreviewProps {
readonly url: string;
}
/**
* Syncs lector's current page with our PdfViewerContext.
* Also handles navigation requests from citations.
* Must be rendered inside lector's Root provider.
*/
const PageSync = () => {
const lectorPage = usePdf((state) => state.currentPage);
const { setCurrentPage, pendingNavigation, clearPendingNavigation } =
usePdfViewer();
const { jumpToPage } = usePdfJump();
// Sync lector page changes to our context (user scrolling)
useEffect(() => {
setCurrentPage(lectorPage);
}, [lectorPage, setCurrentPage]);
// Handle navigation requests from citations
useEffect(() => {
if (pendingNavigation) {
const behavior = pendingNavigation.animate ? "smooth" : "auto";
jumpToPage(pendingNavigation.page, { behavior });
clearPendingNavigation();
}
}, [pendingNavigation, jumpToPage, clearPendingNavigation]);
return null;
};
export const PdfPreview = memo<PdfPreviewProps>(({ url }) => {
return (
<Root
className="flex h-full w-full flex-col overflow-hidden"
source={url}
isZoomFitWidth={true}
loader={<Skeleton className="h-full w-full" />}
>
<PageSync />
<div className="relative flex justify-between border-b p-1">
<ZoomMenu />
<PageNavigation />
<DocumentMenu documentUrl={url} />
</div>
<div className="relative flex-1 overflow-hidden">
<HighlightLayer />
<TextHighlightLayer />
<Pages className="dark:brightness-80 dark:contrast-228 dark:hue-rotate-180 dark:invert-94">
<Page>
<CanvasLayer />
<TextLayer />
</Page>
</Pages>
</div>
</Root>
);
});
PdfPreview.displayName = "PdfPreview";

View File

@@ -0,0 +1,80 @@
import { usePdf, usePdfJump } from "@anaralabs/lector";
import { useEffect, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
export const PageNavigation = () => {
const { t } = useTranslation("ai");
const pages = usePdf((state) => state.pdfDocumentProxy.numPages);
const currentPage = usePdf((state) => state.currentPage);
const [pageNumber, setPageNumber] = useState<string | number>(currentPage);
const { jumpToPage } = usePdfJump();
const handlePreviousPage = () => {
if (currentPage > 1) {
jumpToPage(currentPage - 1, { behavior: "auto" });
}
};
const handleNextPage = () => {
if (currentPage < pages) {
jumpToPage(currentPage + 1, { behavior: "auto" });
}
};
useEffect(() => {
setPageNumber(currentPage);
}, [currentPage]);
return (
<div className="absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 transform flex-row items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={handlePreviousPage}
disabled={currentPage <= 1}
aria-label={t("pdf.preview.navigation.previous")}
className="h-8 w-8"
>
<Icons.ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<input
type="number"
value={pageNumber}
onChange={(e) => setPageNumber(e.target.value)}
onBlur={(e) => {
if (currentPage !== Number(e.target.value)) {
jumpToPage(Number(e.target.value), {
behavior: "auto",
});
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
className="bg-accent focus:ring-primary/20 w-10 [appearance:textfield] rounded-md border-none text-center text-sm font-medium focus:ring-2 focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<span className="text-muted-foreground text-sm font-medium">
/ {pages}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleNextPage}
disabled={currentPage >= pages}
aria-label={t("pdf.preview.navigation.next")}
className="h-8 w-8"
>
<Icons.ChevronRight className="h-4 w-4" />
</Button>
</div>
);
};

View File

@@ -0,0 +1,73 @@
/* Essential styles for PDF text layer - extracted from pdfjs-dist/web/pdf_viewer.css */
/* This avoids importing the full pdfjs CSS which has problematic relative image imports */
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
-webkit-text-size-adjust: none;
-moz-text-size-adjust: none;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 0;
}
.textLayer.highlighting {
touch-action: none;
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
.textLayer {
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
}
.textLayer > :not(.markedContent),
.textLayer .markedContent span:not(.markedContent) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.textLayer .markedContent {
display: contents;
}
.textLayer span[role="img"] {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
cursor: default;
}
.textLayer ::-moz-selection {
background: rgba(0, 0, 255, 0.25);
}
.textLayer ::selection {
background: rgba(0, 0, 255, 0.25);
}
.textLayer br::-moz-selection {
background: transparent;
}
.textLayer br::selection {
background: transparent;
}

View File

@@ -0,0 +1,207 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
"use client";
import { memo, useEffect, useState } from "react";
import { usePdfViewer } from "../../context";
// ============================================================================
// Constants
// ============================================================================
/** Highlight color - yellow for text highlights */
const HIGHLIGHT_COLOR = "rgba(250, 204, 21, 0.4)";
/** Duration in ms before auto-clearing highlights */
const HIGHLIGHT_DURATION_MS = 8000;
// ============================================================================
// Styles
// ============================================================================
/** Injected styles for text span highlighting */
const TEXT_HIGHLIGHT_STYLES = `
.pdf-text-highlight {
background-color: ${HIGHLIGHT_COLOR} !important;
border-radius: 2px;
pointer-events: none;
animation: pdf-highlight-fade-in 300ms ease-in-out;
}
@keyframes pdf-highlight-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
`;
// ============================================================================
// Utilities
// ============================================================================
/**
* Inject highlight styles into document head (once)
*/
function ensureStylesInjected(): void {
if (typeof document === "undefined") return;
if (document.getElementById("pdf-text-highlight-styles")) return;
const style = document.createElement("style");
style.id = "pdf-text-highlight-styles";
style.textContent = TEXT_HIGHLIGHT_STYLES;
document.head.appendChild(style);
}
/**
* Find text in PDF text layer and apply CSS highlights.
* Searches the text layer for exact text matches and applies highlight classes.
*
* @param container - Parent element containing the PDF viewer
* @param text - Text phrase to search for and highlight
* @param pageNumber - Page number where the text should be found
* @returns Number of spans highlighted
*/
function applyTextHighlights(
container: Element,
text: string,
pageNumber: number,
): number {
// Find the text layer for this page
const pageEl = container.querySelector(`[data-page-number="${pageNumber}"]`);
const textLayer =
pageEl?.querySelector(".textLayer") ?? container.querySelector(".textLayer");
if (!textLayer) {
console.debug("[TextHighlightLayer] No TextLayer found for page", pageNumber);
return 0;
}
const spans = textLayer.querySelectorAll("span");
const normalizedSearch = text.toLowerCase().trim();
let highlightCount = 0;
// Build concatenated text from spans to find matches
let fullText = "";
const spanRanges: { start: number; end: number; span: Element }[] = [];
for (const span of spans) {
const spanText = span.textContent ?? "";
const start = fullText.length;
fullText += spanText;
spanRanges.push({ start, end: fullText.length, span });
}
// Find the search text in the full text
const normalizedFull = fullText.toLowerCase();
const foundIndex = normalizedFull.indexOf(normalizedSearch);
if (foundIndex === -1) {
console.debug(
`[TextHighlightLayer] Text not found: "${text.slice(0, 50)}..."`,
);
return 0;
}
const matchEnd = foundIndex + normalizedSearch.length;
// Highlight spans that overlap with the match
for (const { start, end, span } of spanRanges) {
if (end > foundIndex && start < matchEnd) {
span.classList.add("pdf-text-highlight");
span.setAttribute("data-text-highlight", "true");
highlightCount++;
}
}
return highlightCount;
}
/**
* Clear all text highlights from the container
*/
function clearTextHighlights(container: Element | null): void {
if (!container) return;
const highlighted = container.querySelectorAll("[data-text-highlight]");
for (const el of highlighted) {
el.classList.remove("pdf-text-highlight");
el.removeAttribute("data-text-highlight");
}
}
// ============================================================================
// Main Component
// ============================================================================
/**
* TextHighlightLayer - Applies CSS highlights to PDF TextLayer spans based on exact text matches.
*
* This component finds and highlights specific text phrases in the PDF viewer.
* It uses the pdf.js text layer spans and applies CSS classes for highlighting.
*
* When `textHighlights` are set in the PdfViewerContext, this component:
* 1. Finds the text layer spans that contain the search text
* 2. Applies CSS highlight classes to matching spans
* 3. Auto-clears highlights after HIGHLIGHT_DURATION_MS
*
* The component renders a hidden div to get a ref to the parent container.
*/
export const TextHighlightLayer = memo(function TextHighlightLayer() {
const { textHighlights, clearTextHighlights: clearFromContext } = usePdfViewer();
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
// Inject styles once on mount
useEffect(() => {
ensureStylesInjected();
}, []);
// Apply highlights when they change
useEffect(() => {
const container = containerRef?.parentElement;
if (!container || textHighlights.length === 0) {
if (container) clearTextHighlights(container);
return;
}
// Clear previous highlights
clearTextHighlights(container);
// Apply each highlight
for (const highlight of textHighlights) {
const count = applyTextHighlights(container, highlight.text, highlight.page);
console.debug(
`[TextHighlightLayer] Applied ${count} highlights for "${highlight.text.slice(0, 30)}..."`,
);
}
// Auto-clear after duration
const timeout = setTimeout(() => {
clearTextHighlights(container);
clearFromContext();
}, HIGHLIGHT_DURATION_MS);
return () => clearTimeout(timeout);
}, [textHighlights, containerRef, clearFromContext]);
// Cleanup on unmount
useEffect(() => {
return () => {
const container = containerRef?.parentElement;
if (container) {
clearTextHighlights(container);
}
};
}, [containerRef]);
// Hidden container for ref - renders nothing visible
return (
<div
ref={setContainerRef}
data-text-highlight-layer
style={{ display: "none" }}
aria-hidden="true"
/>
);
});
TextHighlightLayer.displayName = "TextHighlightLayer";

View File

@@ -0,0 +1,74 @@
import { usePdf } from "@anaralabs/lector";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
export const ZoomMenu = () => {
const { t } = useTranslation("ai");
const zoom = usePdf((state) => state.zoom);
const setCustomZoom = usePdf((state) => state.updateZoom);
const fitToWidth = usePdf((state) => state.zoomFitWidth);
const handleZoomDecrease = () => setCustomZoom((zoom) => zoom * 0.9);
const handleZoomIncrease = () => setCustomZoom((zoom) => zoom * 1.1);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1"
aria-label={t("pdf.preview.zoom.options")}
>
{Math.round(zoom * 100)}%
<Icons.ChevronUp className="text-muted-foreground h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuItem className="flex justify-between">
<span>{`${Math.round(zoom * 100)}%`}</span>
</DropdownMenuItem>
<Button
variant="ghost"
onClick={handleZoomDecrease}
size="icon"
aria-label={t("pdf.preview.zoom.out")}
>
<Icons.MinusIcon className="size-4" />
</Button>
<Button
variant="ghost"
onClick={handleZoomIncrease}
size="icon"
aria-label={t("pdf.preview.zoom.in")}
>
<Icons.PlusIcon className="size-4" />
</Button>
<DropdownMenuItem onSelect={() => fitToWidth()}>
{t("pdf.preview.zoom.fit")}
</DropdownMenuItem>
{[0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4].map((zoomLevel) => (
<DropdownMenuItem
key={zoomLevel}
onSelect={() => setCustomZoom(zoomLevel)}
>
{`${zoomLevel * 100}%`}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,70 @@
import * as z from "zod";
import { chatSchema } from "@turbostarter/ai/pdf/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import type { InferRequestType } from "hono/client";
const KEY = "pdf";
const queries = {
chats: {
user: {
getAll: (userId: string) => ({
queryKey: [KEY, "chats", userId],
queryFn: handle(api.ai.pdf.chats.$get, {
schema: z.array(chatSchema),
}),
}),
},
documents: {
getAll: (id: string) => ({
queryKey: [KEY, "chats", id, "documents"],
queryFn: () =>
handle(api.ai.pdf.chats[":id"].documents.$get)({
param: { id },
}),
}),
getUrl: (path: string) => ({
queryKey: [KEY, "documents", "url", path],
queryFn: () =>
handle(api.storage.signed.$get)({
query: { path },
}),
}),
getStatus: (documentId: string) => ({
queryKey: [KEY, "documents", documentId, "status"],
queryFn: async () => {
const response = await api.ai.pdf.documents[":id"].status.$get({
param: { id: documentId },
});
return response.json();
},
}),
},
},
};
const mutations = {
chats: {
create: {
mutationKey: [KEY, "chats", "create"],
mutationFn: (data: InferRequestType<typeof api.ai.pdf.chats.$post>) =>
handle(api.ai.pdf.chats.$post)(data),
},
delete: {
mutationKey: [KEY, "chats", "delete"],
mutationFn: ({ id }: { id: string }) =>
handle(api.ai.pdf.chats[":id"].$delete)({
param: { id },
}),
},
},
};
export const pdf = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,90 @@
import { memo, useEffect, useRef } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { cn } from "@turbostarter/ui";
import { ThreadMessageLikes } from "~/modules/common/ai/thread/controls/likes";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { Prose } from "~/modules/common/prose";
import { usePdfViewer } from "../context";
import { CitationMarkdown } from "./citation-markdown";
import { CopyWithCitations } from "./copy-with-citations";
import type { PdfMessage, PreciseCitation } from "@turbostarter/ai/pdf/types";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
/**
* Extract PreciseCitation results from highlightText tool calls in message parts.
* Tool parts are typed as "tool-{toolName}" in Vercel AI SDK.
*/
function extractHighlightCitations(message: PdfMessage): PreciseCitation[] {
const citations: PreciseCitation[] = [];
for (const part of message.parts) {
if (part.type === "tool-highlightText") {
// Tool invocations have different states - only extract when result is available
const toolPart = part as unknown as { state: string; output?: PreciseCitation };
if (toolPart.state === "result" && toolPart.output) {
citations.push(toolPart.output);
}
}
}
return citations;
}
/**
* Assistant message component with citation support.
* Renders AI responses with clickable [[cite:id:page]] markers as interactive citations.
* Also triggers text highlights in the PDF viewer when highlightText tool is invoked.
*/
export const AssistantMessage = memo<ThreadMessageProps<PdfMessage>>(
({ message, ref, status }) => {
const { addTextHighlight } = usePdfViewer();
const processedCitationsRef = useRef<Set<string>>(new Set());
// Process highlightText tool invocations and trigger highlights
useEffect(() => {
const citations = extractHighlightCitations(message);
for (const citation of citations) {
// Only process each citation once (avoid duplicate highlights on re-renders)
if (!processedCitationsRef.current.has(citation.citationId)) {
processedCitationsRef.current.add(citation.citationId);
addTextHighlight(citation);
}
}
}, [message, addTextHighlight]);
const hasTextContent = message.parts.some(
(part) => part.type === "text" && part.text.length > 0
);
return (
<ThreadMessage.Layout className="items-start" ref={ref}>
<Prose className="w-full max-w-none">
<CitationMarkdown
id={message.id}
content={getMessageTextContent(message)}
/>
</Prose>
{!["submitted", "streaming"].includes(status) && (
<div
className={cn(
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs",
"opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3"
)}
>
{hasTextContent && <CopyWithCitations message={message} />}
<ThreadMessageLikes />
</div>
)}
</ThreadMessage.Layout>
);
},
);
AssistantMessage.displayName = "AssistantMessage";

View File

@@ -0,0 +1,247 @@
"use client";
import "katex/dist/katex.min.css";
import { marked } from "marked";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { rehypeInlineCodeProperty } from "react-shiki";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeHighlight } from "~/modules/common/markdown/code";
import { preprocessMarkdown } from "~/modules/common/markdown/utils";
import { Citation } from "../components/citation";
import { CitationPreview } from "../components/citation-preview";
import type { Citation as CitationType } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
interface CitationMarkdownProps {
content: string;
id: string;
}
interface ParsedContent {
/** Content with citation markers replaced with placeholders */
text: string;
/** Parsed citations */
citations: CitationType[];
}
// ============================================================================
// Citation Parser
// ============================================================================
const CITATION_REGEX = /\[\[cite:([a-zA-Z0-9]+):(\d+)\]\]/g;
/**
* Parse citation markers from content and extract citation data
* Returns content with markers replaced by `[n]` placeholders and citation array
*/
function parseCitationsFromContent(content: string): ParsedContent {
const citations: CitationType[] = [];
const seenIds = new Map<string, number>();
const parsedText = content.replace(
CITATION_REGEX,
(_match, embeddingId: string, pageNumStr: string) => {
const pageNumber = parseInt(pageNumStr, 10);
// Check if we've seen this embeddingId before
if (seenIds.has(embeddingId)) {
const existingIndex = seenIds.get(embeddingId)!;
return `[[CITE_PLACEHOLDER:${existingIndex}]]`;
}
// New citation
const index = citations.length + 1;
seenIds.set(embeddingId, index);
citations.push({
index,
embeddingId,
pageNumber,
relevance: 0.9, // Default - actual relevance from embedding search
excerpt: "", // Will be populated if we have the embedding data
});
return `[[CITE_PLACEHOLDER:${index}]]`;
}
);
return { text: parsedText, citations };
}
/**
* Split content into text segments and citation placeholders
*/
function splitContentWithCitations(
content: string,
citations: CitationType[]
): { type: "text" | "citation"; value: string | CitationType }[] {
const PLACEHOLDER_REGEX = /\[\[CITE_PLACEHOLDER:(\d+)\]\]/g;
const segments: { type: "text" | "citation"; value: string | CitationType }[] = [];
let lastIndex = 0;
let match;
while ((match = PLACEHOLDER_REGEX.exec(content)) !== null) {
// Add text before the placeholder
if (match.index > lastIndex) {
segments.push({
type: "text",
value: content.slice(lastIndex, match.index),
});
}
// Add citation
const citationIndex = parseInt(match[1] ?? "0", 10);
const citation = citations.find((c) => c.index === citationIndex);
if (citation) {
segments.push({ type: "citation", value: citation });
}
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < content.length) {
segments.push({ type: "text", value: content.slice(lastIndex) });
}
return segments;
}
// ============================================================================
// Markdown Block Component
// ============================================================================
const MarkdownSegment = memo(({ content }: { content: string }) => {
const processedContent = preprocessMarkdown(content);
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeKatex, rehypeInlineCodeProperty]}
remarkPlugins={[remarkGfm, remarkMath]}
components={{
code: CodeHighlight,
}}
>
{processedContent}
</ReactMarkdown>
);
});
MarkdownSegment.displayName = "MarkdownSegment";
// ============================================================================
// Citation Markdown Component
// ============================================================================
/**
* Markdown renderer with inline citation support.
* Parses [[cite:embeddingId:pageNum]] markers and renders them as clickable citations.
*
* @example
* ```tsx
* <CitationMarkdown
* content="The contract states X [[cite:abc123:5]] and Y [[cite:def456:8]]."
* id="msg-1"
* />
* // Renders markdown with [1] and [2] as clickable citation buttons
* ```
*/
export const CitationMarkdown = memo<CitationMarkdownProps>(
({ content, id }) => {
// Parse citations from raw content
const { text: parsedText, citations } = useMemo(
() => parseCitationsFromContent(content),
[content]
);
// Parse markdown into blocks - use parsedText if we have citations, raw content otherwise
const blocks = useMemo(() => {
const textToParse = citations.length === 0 ? content : parsedText;
const tokens = marked.lexer(textToParse);
return tokens.map((token) => token.raw);
}, [content, parsedText, citations.length]);
// If no citations, render as regular markdown
if (citations.length === 0) {
return blocks.map((block, index) => (
<MarkdownSegment content={block} key={`${id}-block_${index}`} />
));
}
// Render each block, handling citation placeholders
return blocks.map((block, blockIndex) => {
// Check if this block contains citation placeholders
if (!block.includes("[[CITE_PLACEHOLDER:")) {
// For tiny punctuation-only blocks, render as plain inline text
// to avoid orphan dots/punctuation wrapped in <p> tags
const trimmedBlock = block.trim();
if (trimmedBlock.length <= 3 && /^[.,;:!?)\]}>…]+$/.test(trimmedBlock)) {
return <span key={`${id}-block_${blockIndex}`}>{trimmedBlock}</span>;
}
return (
<MarkdownSegment content={block} key={`${id}-block_${blockIndex}`} />
);
}
// Split block into segments with citations
const segments = splitContentWithCitations(block, citations);
return (
<span key={`${id}-block_${blockIndex}`} className="inline">
{segments.map((segment, segIndex) => {
if (segment.type === "text") {
let textContent = segment.value as string;
// Strip leading dot if this segment follows a citation
// (dots after citations look bad visually)
const prevSegment = segments[segIndex - 1];
if (prevSegment?.type === "citation") {
textContent = textContent.replace(/^\./, "");
}
const trimmed = textContent.trim();
// Skip empty segments after stripping
if (!trimmed) {
return null;
}
// For text segments, render inline markdown
return (
<MarkdownSegment
key={`${id}-seg_${blockIndex}_${segIndex}`}
content={textContent}
/>
);
}
// Citation segment - render interactive citation
const citation = segment.value as CitationType;
return (
<CitationPreview
key={`${id}-cite_${blockIndex}_${segIndex}`}
citation={citation}
>
<Citation citation={citation} />
</CitationPreview>
);
})}
</span>
);
});
}
);
CitationMarkdown.displayName = "CitationMarkdown";
export default CitationMarkdown;

View File

@@ -0,0 +1,129 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { getMessageTextContent } from "@turbostarter/ai";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@turbostarter/ui-web/tooltip";
import { useCopy } from "~/modules/common/hooks/use-copy";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
// ============================================================================
// Types
// ============================================================================
interface CopyWithCitationsProps {
message: PdfMessage;
}
// ============================================================================
// Helpers
// ============================================================================
const CITATION_REGEX = /\[\[cite:([a-zA-Z0-9]+):(\d+)\]\]/g;
/**
* Format message content with readable citations.
* Replaces [[cite:embeddingId:pageNum]] with [p.X] format.
*/
function formatContentWithCitations(content: string): string {
const seenPages = new Map<string, number>();
let citationIndex = 0;
return content.replace(
CITATION_REGEX,
(_match, _embeddingId: string, pageNumStr: string) => {
const pageNumber = pageNumStr;
// Track unique citations
if (!seenPages.has(pageNumber)) {
citationIndex++;
seenPages.set(pageNumber, citationIndex);
}
return `[p.${pageNumber}]`;
}
);
}
// ============================================================================
// Animation
// ============================================================================
const transition = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
transition: { duration: 0.1, ease: "easeInOut" as const },
};
// ============================================================================
// Component
// ============================================================================
/**
* Copy button that formats citations as readable page references.
* Transforms [[cite:xxx:5]] into [p.5] for clean clipboard content.
*/
export function CopyWithCitations({ message }: CopyWithCitationsProps) {
const { t } = useTranslation("common");
const { copied, copy } = useCopy();
const handleCopy = () => {
const rawContent = getMessageTextContent(message);
const formattedContent = formatContentWithCitations(rawContent);
void copy(formattedContent);
};
return (
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group/button size-8 rounded-full"
onClick={handleCopy}
>
<div className="relative size-3.5">
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.div
key="check"
{...transition}
className="absolute inset-0"
>
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
) : (
<motion.div
key="copy"
{...transition}
className="absolute inset-0"
>
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
</motion.div>
)}
</AnimatePresence>
</div>
<span className="sr-only">{t("copy")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("copy")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default CopyWithCitations;

View File

@@ -0,0 +1,69 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface FollowUpSuggestionsProps {
onSelect: (question: string) => void;
disabled?: boolean;
}
// ============================================================================
// Follow-up Suggestions
// ============================================================================
/**
* Contextual follow-up questions shown after AI responses.
* Helps users continue the conversation with relevant questions.
*/
export function FollowUpSuggestions({
onSelect,
disabled,
}: FollowUpSuggestionsProps) {
const { t } = useTranslation("ai");
const suggestions = [
{
icon: Icons.Search,
text: t("pdf.followUp.moreDetail"),
},
{
icon: Icons.MessageCircle,
text: t("pdf.followUp.clarify"),
},
{
icon: Icons.ArrowRight,
text: t("pdf.followUp.continue"),
},
];
return (
<div className="flex flex-wrap gap-2 pt-2 pb-4">
{suggestions.map((s, i) => (
<Button
key={i}
variant="outline"
size="sm"
className={cn(
"h-auto gap-2 px-3 py-1.5 text-xs",
"hover:bg-accent hover:text-accent-foreground",
"border-dashed transition-colors"
)}
onClick={() => onSelect(s.text)}
disabled={disabled}
>
<s.icon className="text-muted-foreground size-3 shrink-0" />
<span>{s.text}</span>
</Button>
))}
</div>
);
}
export default FollowUpSuggestions;

View File

@@ -0,0 +1,112 @@
"use client";
import { useCallback, useMemo } from "react";
import { Role } from "@turbostarter/ai/pdf/types";
import { useTranslation } from "@turbostarter/i18n";
import { Icons } from "@turbostarter/ui-web/icons";
import { Thread } from "~/modules/common/ai/thread";
import { TextSelectionAction } from "../components/text-selection-action";
import { useComposer } from "../composer/use-composer";
import { AssistantMessage } from "./assistant";
import { FollowUpSuggestions } from "./follow-up-suggestions";
import { SuggestedQuestions } from "./suggested-questions";
import { UserMessage } from "./user";
import type { PdfMessage } from "@turbostarter/ai/pdf/types";
interface ChatProps {
readonly id?: string;
readonly initialMessages?: PdfMessage[];
}
const components = {
[Role.USER]: UserMessage,
[Role.ASSISTANT]: AssistantMessage,
};
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
const { t } = useTranslation("ai");
const { messages, regenerate, error, status, sendMessage } = useComposer({
id,
initialMessages,
});
const handleSuggestedQuestion = useCallback(
(question: string) => {
void sendMessage({ text: question });
},
[sendMessage]
);
const handleAskAboutSelection = useCallback(
(selectedText: string) => {
// Format the question with the selected text
const question = `Regarding this text from the document: "${selectedText}"\n\nCan you explain what this means?`;
void sendMessage({ text: question });
},
[sendMessage]
);
// Show follow-up suggestions after the last assistant message when ready
const showFollowUp = useMemo(() => {
if (status !== "ready") return false;
if (messages.length === 0) return false;
return messages.at(-1)?.role === Role.ASSISTANT;
}, [status, messages]);
const isDisabled = ["submitted", "streaming"].includes(status);
if (!messages.length) {
return (
<>
<TextSelectionAction
onAskAbout={handleAskAboutSelection}
disabled={isDisabled}
/>
<div className="flex w-full grow flex-col items-center justify-center gap-6 px-6">
<div className="flex flex-col items-center gap-2">
<Icons.ScrollText className="text-muted-foreground size-20 stroke-[1.5]" />
<p className="text-muted-foreground max-w-sm text-center">
{t("pdf.composer.empty")}
</p>
</div>
<SuggestedQuestions
onSelect={handleSuggestedQuestion}
disabled={isDisabled}
/>
</div>
</>
);
}
return (
<>
<TextSelectionAction
onAskAbout={handleAskAboutSelection}
disabled={isDisabled}
/>
<Thread
messages={messages}
initialMessages={initialMessages}
status={status}
components={components}
error={error}
regenerate={regenerate}
footer={
showFollowUp ? (
<div className="mx-auto w-full max-w-3xl px-4">
<FollowUpSuggestions
onSelect={handleSuggestedQuestion}
disabled={isDisabled}
/>
</div>
) : null
}
/>
</>
);
};

View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
// ============================================================================
// Types
// ============================================================================
interface SuggestedQuestionsProps {
onSelect: (question: string) => void;
disabled?: boolean;
}
// ============================================================================
// Suggested Questions
// ============================================================================
/**
* Starter questions shown when chat is empty.
* Clicking a question sends it as the first message.
*/
export function SuggestedQuestions({
onSelect,
disabled,
}: SuggestedQuestionsProps) {
const { t } = useTranslation("ai");
const questions = [
{
icon: Icons.FileText,
text: t("pdf.suggestions.summarize"),
},
{
icon: Icons.BookOpen,
text: t("pdf.suggestions.keyPoints"),
},
{
icon: Icons.Lightbulb,
text: t("pdf.suggestions.explain"),
},
{
icon: Icons.Search,
text: t("pdf.suggestions.find"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-muted-foreground text-center text-sm">
{t("pdf.suggestions.title")}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{questions.map((q, i) => (
<Button
key={i}
variant="outline"
className={cn(
"h-auto justify-start gap-3 px-4 py-3 text-left",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors"
)}
onClick={() => onSelect(q.text)}
disabled={disabled}
>
<q.icon className="text-muted-foreground size-4 shrink-0" />
<span className="line-clamp-2 text-sm">{q.text}</span>
</Button>
))}
</div>
</div>
);
}
export default SuggestedQuestions;

View File

@@ -0,0 +1,20 @@
import { memo } from "react";
import { getMessageTextContent } from "@turbostarter/ai";
import { ThreadMessage } from "~/modules/common/ai/thread/message";
import { Prose } from "~/modules/common/prose";
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
export const UserMessage = memo<ThreadMessageProps>(({ message, ref }) => {
return (
<ThreadMessage.Layout className="items-end" ref={ref}>
<Prose className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]">
{getMessageTextContent(message)}
</Prose>
</ThreadMessage.Layout>
);
});
UserMessage.displayName = "UserMessage";

View File

@@ -0,0 +1,207 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { formatFileSize } from "@turbostarter/ai/pdf/utils";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { useAIError } from "~/modules/common/hooks/use-ai-error";
import { pdf } from "../lib/api";
import { useUpload } from "./hooks/use-upload";
import { getFileName } from "./utils";
import type { FileInput } from "./utils";
const formSchema = z.object({
name: z.string().min(1).max(255),
});
interface PdfUploadConfirmProps {
readonly file: FileInput;
readonly onCancel: () => void;
}
export const PdfUploadConfirm = ({ file, onCancel }: PdfUploadConfirmProps) => {
const { onError } = useAIError();
const { t } = useTranslation(["common", "ai"]);
const { data: session, isPending: isSessionLoading } = authClient.useSession();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: getFileName(file) ?? "",
},
});
const router = useRouter();
const upload = useUpload();
const createChat = useMutation({
...pdf.mutations.chats.create,
onSuccess: (data) => {
toast.success(t("pdf.upload.success"));
if (data?.id) {
return router.push(pathsConfig.apps.pdf.chat(data.id));
}
},
onError,
});
// Show sign-in prompt for non-authenticated users
if (!isSessionLoading && !session?.user) {
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="flex flex-col items-center gap-1 py-8">
<Icons.Lock className="mb-2 size-10 text-muted-foreground" />
<CardTitle>{t("pdf.upload.signIn.title")}</CardTitle>
<CardDescription className="text-center">
{t("pdf.upload.signIn.description")}
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-center gap-2 pb-8">
<Button variant="outline" onClick={onCancel}>
{t("cancel")}
</Button>
<Button asChild>
<Link href={pathsConfig.auth.login}>
{t("pdf.upload.signIn.cta")}
</Link>
</Button>
</CardFooter>
</Card>
);
}
const data = [
{
title: t("file"),
value: "url" in file ? file.url : `./${file.name}`,
},
{
title: t("size"),
value: formatFileSize(file.size),
},
{
title: t("type"),
value: "PDF",
},
];
async function onSubmit(values: z.infer<typeof formSchema>) {
const { path } = await upload.mutateAsync({
file,
...values,
});
await createChat.mutateAsync({
json: {
...values,
path,
},
});
}
if (form.formState.isSubmitting || form.formState.isSubmitSuccessful) {
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="flex flex-col items-center gap-1 py-16">
<Icons.Loader className="mb-2 size-8 animate-spin" />
<CardTitle>{t("pdf.upload.loading.title")}</CardTitle>
<CardDescription>
{t("pdf.upload.loading.description")}
</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="relative z-10 flex w-full max-w-xl flex-col justify-center gap-3 overflow-hidden">
<CardHeader className="pb-2">
<CardTitle>{t("pdf.upload.confirm.title")}</CardTitle>
<CardDescription>{t("pdf.upload.confirm.description")}</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-8 @md:gap-12">
<FormLabel className="font-medium">
{t("name")}:
</FormLabel>
<FormControl>
<Input
autoFocus
className="h-8 py-0 text-right"
{...field}
/>
</FormControl>
</div>
<FormMessage className="text-right" />
</FormItem>
)}
/>
{data.map(({ title, value }) => (
<div
key={title}
className="mb-0.5 flex items-center justify-between gap-8 text-sm @md:gap-12"
>
<span className="font-medium">{title}:</span>
<span className="truncate">{value}</span>
</div>
))}
</div>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="grow @lg:grow-0"
>
{t("cancel")}
</Button>
<Button type="submit" className="grow @lg:grow-0">
{t("confirm")}
</Button>
</CardFooter>
</form>
</Form>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { useMutation } from "@tanstack/react-query";
import { handle } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { generateId } from "@turbostarter/shared/utils";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import { readFile } from "~/modules/pdf/upload/utils";
import type { FileInput } from "~/modules/pdf/upload/utils";
export const useUpload = () => {
const { t } = useTranslation("ai");
const { data: session } = authClient.useSession();
return useMutation({
mutationFn: async (data: { file: FileInput }) => {
if (!session?.user.id) {
throw new Error(t("pdf.upload.error.unauthorized"));
}
const path = `documents/${session.user.id}/${generateId()}.pdf`;
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
query: { path },
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: await readFile(data.file),
headers: {
"Content-Type": "application/pdf",
},
});
if (!response.ok) {
throw new Error(t("pdf.upload.error.api"));
}
return { path };
},
});
};

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ErrorCode, useDropzone as useReactDropzone } from "react-dropzone";
import { toast } from "sonner";
import {
EXAMPLE_PDF,
MAX_FILE_SIZE,
MAX_FILE_SIZE_IN_MB,
} from "@turbostarter/ai/pdf/constants";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
import { Icons } from "@turbostarter/ui-web/icons";
import { PdfUploadConfirm } from "~/modules/pdf/upload/confirm";
import { PdfUrlForm } from "./url-form";
import type { FileInput } from "./utils";
const useDropzone = ({ onDrop }: { onDrop: (files: File[]) => void }) => {
const { t } = useTranslation("validation");
const errorMessages = useMemo(
() => ({
[ErrorCode.FileInvalidType]: t("error.file.type", {
type: "PDF",
}),
[ErrorCode.FileTooLarge]: t("error.tooBig.file.notInclusive", {
maximum: MAX_FILE_SIZE_IN_MB,
}),
[ErrorCode.FileTooSmall]: t("error.tooSmall.file.notInclusive", {
minimum: 0,
}),
[ErrorCode.TooManyFiles]: t("error.file.maxCount", {
count: 1,
}),
}),
[t],
);
const dropzone = useReactDropzone({
accept: {
"application/pdf": [".pdf"],
},
maxFiles: 1,
maxSize: MAX_FILE_SIZE * 1024 * 1024,
onError: (error) => toast.error(error.message),
noClick: true,
noKeyboard: true,
onDrop,
});
useEffect(() => {
const code = dropzone.fileRejections[0]?.errors[0]?.code;
if (code) {
toast.error(errorMessages[code as ErrorCode]);
}
}, [dropzone.fileRejections, errorMessages]);
return dropzone;
};
export const PdfUpload = () => {
const [file, setFile] = useState<FileInput | null>(null);
const { t } = useTranslation(["ai", "common"]);
const { open, getRootProps, getInputProps, isDragAccept, isDragReject } =
useDropzone({ onDrop: (files) => setFile(files[0] ?? null) });
if (file) {
return (
<Layout>
<PdfUploadConfirm file={file} onCancel={() => setFile(null)} />
</Layout>
);
}
return (
<Layout
{...getRootProps()}
className={cn(
{ "border-destructive": isDragReject },
{ "border-muted-foreground": isDragAccept },
)}
>
<input {...getInputProps()} />
<div className="relative z-10 flex w-full max-w-md flex-col items-center justify-center gap-3">
<Icons.FileText className="size-16" />
<h1 className="text-4xl font-medium tracking-tight">
{t("pdf.title")}
</h1>
<p className="text-muted-foreground mb-6 max-w-sm text-center text-sm">
{t("pdf.upload.description")}
</p>
<PdfUrlForm onSuccess={setFile} />
<Button
className="bg-background w-full rounded-md"
variant="outline"
onClick={open}
>
{t("pdf.upload.fromDevice")}
</Button>
<span className="text-muted-foreground text-sm">{t("or")}</span>
<Button
variant="outline"
className="bg-background h-auto w-full gap-3 overflow-hidden rounded-md px-2 pr-4"
onClick={() => setFile(EXAMPLE_PDF)}
>
<div className="bg-muted rounded-md border p-1.5">
<Icons.Paperclip className="size-4 shrink-0" />
</div>
<div className="mr-auto flex min-w-0 flex-col items-start">
<span>{t("pdf.upload.example.cta")}</span>
<span className="text-muted-foreground w-full truncate pr-4 text-xs">
{EXAMPLE_PDF.url}
</span>
</div>
<Icons.ArrowRight className="size-4 shrink-0" />
</Button>
</div>
</Layout>
);
};
const Layout = ({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
"relative flex h-full w-full items-center justify-center rounded-lg border-2 border-dashed p-4",
className,
)}
{...props}
>
{children}
<GridPattern
width={50}
height={50}
x={-1}
y={-1}
strokeDasharray={"4 2"}
className="mask-[radial-gradient(white,transparent)]"
/>
</div>
);
};

View File

@@ -0,0 +1,88 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { MAX_FILE_SIZE_IN_MB } from "@turbostarter/ai/pdf/constants";
import {
pdfUrlFormSchema,
validateRemotePdfUrl,
} from "@turbostarter/ai/pdf/schema";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import type { PdfUrlFormPayload } from "@turbostarter/ai/pdf/schema";
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
interface PdfUrlFormProps {
readonly onSuccess: (file: RemoteFile) => void;
}
export const PdfUrlForm = ({ onSuccess }: PdfUrlFormProps) => {
const { t } = useTranslation(["common", "ai", "validation"]);
const form = useForm({
resolver: zodResolver(pdfUrlFormSchema),
defaultValues: {
url: "",
},
});
async function onSubmit(values: PdfUrlFormPayload) {
const result = await validateRemotePdfUrl(values.url);
if (typeof result === "string") {
return form.setError("url", {
message: t(result, { maximum: MAX_FILE_SIZE_IN_MB, type: "PDF" }),
});
}
onSuccess(result);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full gap-2"
>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
autoFocus
placeholder={t("pdf.upload.fromUrl.placeholder")}
className="bg-background"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="rounded-md"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="size-5 animate-spin" />
) : (
t("pdf.upload.fromUrl.cta")
)}
</Button>
</form>
</Form>
);
};

View File

@@ -0,0 +1,37 @@
import type { RemoteFile } from "@turbostarter/ai/pdf/types";
export type FileInput = File | RemoteFile;
export const getFileName = (file: FileInput) => {
if ("name" in file) {
return file.name.replace(/\.[^.]+$/, "");
}
const fileName = file.url.split("/").pop() ?? null;
if (!fileName) return null;
return fileName.replace(/\.[^.]+$/, "");
};
export const readFile = async (file: FileInput) => {
if ("url" in file) {
// Use server proxy to fetch external URLs (avoids CORS issues)
const proxyUrl = `/api/storage/proxy?url=${encodeURIComponent(file.url)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
const error = await response.json().catch(() => ({})) as { error?: string };
throw new Error(error.error ?? "Failed to fetch PDF from URL");
}
const blob = await response.blob();
return blob;
} else {
const reader = new FileReader();
return new Promise<Blob>((resolve, reject) => {
reader.onloadend = () =>
resolve(new Blob([reader.result as ArrayBuffer]));
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
}
};