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:
140
apps/web/src/modules/pdf/components/citation-preview.tsx
Normal file
140
apps/web/src/modules/pdf/components/citation-preview.tsx
Normal 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;
|
||||
86
apps/web/src/modules/pdf/components/citation.tsx
Normal file
86
apps/web/src/modules/pdf/components/citation.tsx
Normal 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;
|
||||
8
apps/web/src/modules/pdf/components/index.ts
Normal file
8
apps/web/src/modules/pdf/components/index.ts
Normal 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";
|
||||
37
apps/web/src/modules/pdf/components/navigation-controls.tsx
Normal file
37
apps/web/src/modules/pdf/components/navigation-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/modules/pdf/components/recent-chats.tsx
Normal file
136
apps/web/src/modules/pdf/components/recent-chats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
apps/web/src/modules/pdf/components/text-selection-action.tsx
Normal file
126
apps/web/src/modules/pdf/components/text-selection-action.tsx
Normal 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;
|
||||
82
apps/web/src/modules/pdf/composer/index.tsx
Normal file
82
apps/web/src/modules/pdf/composer/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
apps/web/src/modules/pdf/composer/use-composer.tsx
Normal file
90
apps/web/src/modules/pdf/composer/use-composer.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
6
apps/web/src/modules/pdf/context/index.ts
Normal file
6
apps/web/src/modules/pdf/context/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
PdfViewerProvider,
|
||||
usePdfViewer,
|
||||
useCanGoBack,
|
||||
useCanGoForward,
|
||||
} from "./pdf-viewer-context";
|
||||
242
apps/web/src/modules/pdf/context/pdf-viewer-context.tsx
Normal file
242
apps/web/src/modules/pdf/context/pdf-viewer-context.tsx
Normal 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;
|
||||
}
|
||||
25
apps/web/src/modules/pdf/history/actions.tsx
Normal file
25
apps/web/src/modules/pdf/history/actions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
apps/web/src/modules/pdf/history/index.tsx
Normal file
96
apps/web/src/modules/pdf/history/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/modules/pdf/history/list/index.tsx
Normal file
57
apps/web/src/modules/pdf/history/list/index.tsx
Normal 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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
166
apps/web/src/modules/pdf/history/list/item.tsx
Normal file
166
apps/web/src/modules/pdf/history/list/item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
apps/web/src/modules/pdf/hooks/index.ts
Normal file
7
apps/web/src/modules/pdf/hooks/index.ts
Normal 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";
|
||||
73
apps/web/src/modules/pdf/hooks/use-citation-unit.ts
Normal file
73
apps/web/src/modules/pdf/hooks/use-citation-unit.ts
Normal 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
|
||||
});
|
||||
}
|
||||
48
apps/web/src/modules/pdf/hooks/use-embedding.ts
Normal file
48
apps/web/src/modules/pdf/hooks/use-embedding.ts
Normal 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
|
||||
});
|
||||
}
|
||||
26
apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts
Normal file
26
apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
29
apps/web/src/modules/pdf/index.ts
Normal file
29
apps/web/src/modules/pdf/index.ts
Normal 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";
|
||||
209
apps/web/src/modules/pdf/layout/layout.tsx
Normal file
209
apps/web/src/modules/pdf/layout/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
apps/web/src/modules/pdf/layout/preview/document-menu.tsx
Normal file
81
apps/web/src/modules/pdf/layout/preview/document-menu.tsx
Normal 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;
|
||||
551
apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx
Normal file
551
apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx
Normal 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";
|
||||
93
apps/web/src/modules/pdf/layout/preview/index.tsx
Normal file
93
apps/web/src/modules/pdf/layout/preview/index.tsx
Normal 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";
|
||||
80
apps/web/src/modules/pdf/layout/preview/page-navigation.tsx
Normal file
80
apps/web/src/modules/pdf/layout/preview/page-navigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
apps/web/src/modules/pdf/layout/preview/pdf-viewer.css
Normal file
73
apps/web/src/modules/pdf/layout/preview/pdf-viewer.css
Normal 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;
|
||||
}
|
||||
207
apps/web/src/modules/pdf/layout/preview/text-highlight-layer.tsx
Normal file
207
apps/web/src/modules/pdf/layout/preview/text-highlight-layer.tsx
Normal 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";
|
||||
74
apps/web/src/modules/pdf/layout/preview/zoom-menu.tsx
Normal file
74
apps/web/src/modules/pdf/layout/preview/zoom-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
70
apps/web/src/modules/pdf/lib/api.ts
Normal file
70
apps/web/src/modules/pdf/lib/api.ts
Normal 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;
|
||||
90
apps/web/src/modules/pdf/thread/assistant.tsx
Normal file
90
apps/web/src/modules/pdf/thread/assistant.tsx
Normal 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";
|
||||
247
apps/web/src/modules/pdf/thread/citation-markdown.tsx
Normal file
247
apps/web/src/modules/pdf/thread/citation-markdown.tsx
Normal 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;
|
||||
129
apps/web/src/modules/pdf/thread/copy-with-citations.tsx
Normal file
129
apps/web/src/modules/pdf/thread/copy-with-citations.tsx
Normal 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;
|
||||
69
apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx
Normal file
69
apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx
Normal 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;
|
||||
112
apps/web/src/modules/pdf/thread/index.tsx
Normal file
112
apps/web/src/modules/pdf/thread/index.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
apps/web/src/modules/pdf/thread/suggested-questions.tsx
Normal file
77
apps/web/src/modules/pdf/thread/suggested-questions.tsx
Normal 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;
|
||||
20
apps/web/src/modules/pdf/thread/user.tsx
Normal file
20
apps/web/src/modules/pdf/thread/user.tsx
Normal 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";
|
||||
207
apps/web/src/modules/pdf/upload/confirm.tsx
Normal file
207
apps/web/src/modules/pdf/upload/confirm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
apps/web/src/modules/pdf/upload/hooks/use-upload.tsx
Normal file
44
apps/web/src/modules/pdf/upload/hooks/use-upload.tsx
Normal 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 };
|
||||
},
|
||||
});
|
||||
};
|
||||
155
apps/web/src/modules/pdf/upload/index.tsx
Normal file
155
apps/web/src/modules/pdf/upload/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
88
apps/web/src/modules/pdf/upload/url-form.tsx
Normal file
88
apps/web/src/modules/pdf/upload/url-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
apps/web/src/modules/pdf/upload/utils.ts
Normal file
37
apps/web/src/modules/pdf/upload/utils.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user