(
- ({ 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) => (
-
- ));
- }
-
- // 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 tags
- const trimmedBlock = block.trim();
- if (trimmedBlock.length <= 3 && /^[.,;:!?)\]}>…]+$/.test(trimmedBlock)) {
- return {trimmedBlock};
- }
- return (
-
- );
- }
-
- // Split block into segments with citations
- const segments = splitContentWithCitations(block, citations);
-
- return (
-
- {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 (
-
- );
- }
-
- // Citation segment - render interactive citation
- const citation = segment.value as CitationType;
- return (
-
-
-
- );
- })}
-
- );
- });
- }
-);
-
-CitationMarkdown.displayName = "CitationMarkdown";
-
-export default CitationMarkdown;
diff --git a/apps/web/src/modules/pdf/thread/copy-with-citations.tsx b/apps/web/src/modules/pdf/thread/copy-with-citations.tsx
deleted file mode 100644
index 690f668..0000000
--- a/apps/web/src/modules/pdf/thread/copy-with-citations.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-"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();
- 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 (
-
-
-
-
-
-
- {t("copy")}
-
-
-
- );
-}
-
-export default CopyWithCitations;
diff --git a/apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx b/apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx
deleted file mode 100644
index fe05528..0000000
--- a/apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-"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 (
-
- {suggestions.map((s, i) => (
-
- ))}
-
- );
-}
-
-export default FollowUpSuggestions;
diff --git a/apps/web/src/modules/pdf/thread/index.tsx b/apps/web/src/modules/pdf/thread/index.tsx
deleted file mode 100644
index 53e294e..0000000
--- a/apps/web/src/modules/pdf/thread/index.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-"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 (
- <>
-
-
-
-
-
- {t("pdf.composer.empty")}
-
-
-
-
- >
- );
- }
-
- return (
- <>
-
-
-
-