From 933de75b733326f9e114b5aea0e52ebcc241dea3 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 25 Oct 2025 10:26:21 -0300 Subject: [PATCH] feat: implement compact chat references with numbered citations (#220) Transform verbose inline references to compact footnote-style format: - Replace [source:abc123] with numbered citations [1], [2], etc. - Add "References:" section at bottom of messages - Deduplicate references (same ID gets same number) - Remove icons from citations for cleaner appearance - Maintain click-to-open functionality for all reference types Changes: - Add convertReferencesToCompactMarkdown() function - Add createCompactReferenceLinkComponent() function - Update ChatPanel to use new compact reference functions - Add ReferenceData interface for type safety Improves readability by reducing visual clutter while preserving all existing functionality. References restart numbering at 1 for each message. --- .../notebooks/components/NotesColumn.tsx | 2 +- frontend/src/components/source/ChatPanel.tsx | 12 +- frontend/src/lib/utils/source-references.tsx | 163 ++++++++++++++++++ 3 files changed, 170 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/(dashboard)/notebooks/components/NotesColumn.tsx b/frontend/src/app/(dashboard)/notebooks/components/NotesColumn.tsx index 048c8e6..cd004a5 100644 --- a/frontend/src/app/(dashboard)/notebooks/components/NotesColumn.tsx +++ b/frontend/src/app/(dashboard)/notebooks/components/NotesColumn.tsx @@ -105,7 +105,7 @@ export function NotesColumn({ ) : ( )} - + {note.note_type === 'ai' ? 'AI Generated' : 'Human'} diff --git a/frontend/src/components/source/ChatPanel.tsx b/frontend/src/components/source/ChatPanel.tsx index 0e935e9..270ad97 100644 --- a/frontend/src/components/source/ChatPanel.tsx +++ b/frontend/src/components/source/ChatPanel.tsx @@ -18,7 +18,7 @@ import { ModelSelector } from './ModelSelector' import { ContextIndicator } from '@/components/common/ContextIndicator' import { SessionManager } from '@/components/source/SessionManager' import { MessageActions } from '@/components/source/MessageActions' -import { convertReferencesToMarkdownLinks, createReferenceLinkComponent } from '@/lib/utils/source-references' +import { convertReferencesToCompactMarkdown, createCompactReferenceLinkComponent } from '@/lib/utils/source-references' import { useModalManager } from '@/lib/hooks/use-modal-manager' import { toast } from 'sonner' @@ -326,11 +326,11 @@ function AIMessageContent({ content: string onReferenceClick: (type: string, id: string) => void }) { - // Convert references to markdown links - const markdownWithLinks = convertReferencesToMarkdownLinks(content) + // Convert references to compact markdown with numbered citations + const markdownWithCompactRefs = convertReferencesToCompactMarkdown(content) - // Create custom link component - const LinkComponent = createReferenceLinkComponent(onReferenceClick) + // Create custom link component for compact references + const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick) return (
@@ -349,7 +349,7 @@ function AIMessageContent({ ol: ({ children }) =>
    {children}
, }} > - {markdownWithLinks} + {markdownWithCompactRefs}
) diff --git a/frontend/src/lib/utils/source-references.tsx b/frontend/src/lib/utils/source-references.tsx index a76a645..26f013e 100644 --- a/frontend/src/lib/utils/source-references.tsx +++ b/frontend/src/lib/utils/source-references.tsx @@ -23,6 +23,12 @@ export interface ExtractedReferences { references: ExtractedReference[] } +export interface ReferenceData { + number: number + type: ReferenceType + id: string +} + /** * Parse source references from text * @@ -307,6 +313,163 @@ export function createReferenceLinkComponent( return ReferenceLinkComponent } +/** + * Convert references in text to compact numbered format with reference list + * + * This function transforms verbose inline references like [source:abc123] into + * compact numbered citations [1], [2], etc., and appends a "References:" section + * at the bottom of the message with the full reference details. + * + * Algorithm: + * 1. Parse all references using parseSourceReferences() + * 2. Build a reference map to deduplicate and assign numbers + * 3. Replace inline references with numbered citations + * 4. Append reference list at the bottom + * + * @param text - Original text with references + * @returns Text with numbered citations and reference list appended + * + * @example + * Input: "See [source:abc] and [note:xyz]. Also [source:abc] again." + * Output: "See [1] and [2]. Also [1] again.\n\nReferences:\n[1] - [source:abc]\n[2] - [note:xyz]" + */ +export function convertReferencesToCompactMarkdown(text: string): string { + // Step 1: Parse all references using existing function + const references = parseSourceReferences(text) + + // Step 2: If no references found, return original text + if (references.length === 0) { + return text + } + + // Step 3: Build reference map (deduplicate and assign numbers) + const referenceMap = new Map() + let nextNumber = 1 + + for (const reference of references) { + const key = `${reference.type}:${reference.id}` + if (!referenceMap.has(key)) { + referenceMap.set(key, { + number: nextNumber++, + type: reference.type, + id: reference.id + }) + } + } + + // Step 4: Replace references with numbered citations (process from end to start) + let result = text + for (let i = references.length - 1; i >= 0; i--) { + const reference = references[i] + const key = `${reference.type}:${reference.id}` + const refData = referenceMap.get(key)! + const number = refData.number + + // Analyze context around the reference + const refStart = reference.startIndex + const refEnd = reference.endIndex + const contextBefore = result.substring(Math.max(0, refStart - 2), refStart) + const contextAfter = result.substring(refEnd, Math.min(result.length, refEnd + 2)) + + // Determine what to replace based on bracket context + let replaceStart = refStart + let replaceEnd = refEnd + + // Check for double brackets [[ref]] + if (contextBefore === '[[' && contextAfter.startsWith(']]')) { + replaceStart = refStart - 2 + replaceEnd = refEnd + 2 + } + // Check for single brackets [ref] + else if (contextBefore.endsWith('[') && contextAfter.startsWith(']')) { + replaceStart = refStart - 1 + replaceEnd = refEnd + 1 + } + + // Build the numbered citation with full reference in href + const citationLink = `[${number}](#ref-${reference.type}-${reference.id})` + + // Replace in the result string + result = result.substring(0, replaceStart) + citationLink + result.substring(replaceEnd) + } + + // Step 5: Build reference list + const refListLines: string[] = ['\n\nReferences:'] + + // Iterate through reference map in insertion order (Map preserves order) + for (const [, refData] of referenceMap) { + const refListItem = `[${refData.number}] - [${refData.type}:${refData.id}](#ref-${refData.type}-${refData.id})` + refListLines.push(refListItem) + } + + // Step 6: Append reference list to result + result = result + refListLines.join('\n') + + return result +} + +/** + * Create a custom link component for ReactMarkdown that handles compact reference links + * + * This component handles two types of reference links: + * 1. Numbered citations in text: [1](#ref-source-abc123) + * 2. Reference list items: [source:abc123](#ref-source-abc123) + * + * Both use the same href format: #ref-{type}-{id} + * The component extracts the type and id from the href and triggers the click handler. + * + * @param onReferenceClick - Callback for when a reference link is clicked + * @returns React component for rendering links in ReactMarkdown + * + * @example + * const LinkComponent = createCompactReferenceLinkComponent((type, id) => openModal(type, id)) + * ... + */ +export function createCompactReferenceLinkComponent( + onReferenceClick: (type: ReferenceType, id: string) => void +) { + const CompactReferenceLinkComponent = ({ + href, + children, + ...props + }: React.AnchorHTMLAttributes & { + href?: string + children?: React.ReactNode + }) => { + // Check if this is a reference link (starts with #ref-) + if (href?.startsWith('#ref-')) { + // Parse: #ref-source-abc123 → type=source, id=abc123 + const parts = href.substring(5).split('-') // Remove '#ref-' + const type = parts[0] as ReferenceType + const id = parts.slice(1).join('-') // Rejoin in case ID has dashes + + return ( + + ) + } + + // Regular link - open in new tab + return ( + + {children} + + ) + } + + CompactReferenceLinkComponent.displayName = 'CompactReferenceLinkComponent' + return CompactReferenceLinkComponent +} + /** * Legacy function for backward compatibility * Converts old Link-based references to new click handler approach