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.
This commit is contained in:
Luis Novo 2025-10-25 10:26:21 -03:00 committed by GitHub
parent a287d3b248
commit 933de75b73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 170 additions and 7 deletions

View file

@ -105,7 +105,7 @@ export function NotesColumn({
) : (
<User className="h-4 w-4 text-muted-foreground" />
)}
<Badge variant={note.note_type === 'ai' ? 'default' : 'secondary'} className="text-xs">
<Badge variant="secondary" className="text-xs">
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
</Badge>
</div>

View file

@ -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 (
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words prose-headings:font-semibold prose-a:text-blue-600 prose-a:break-all prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2">
@ -349,7 +349,7 @@ function AIMessageContent({
ol: ({ children }) => <ol className="mb-4 space-y-1">{children}</ol>,
}}
>
{markdownWithLinks}
{markdownWithCompactRefs}
</ReactMarkdown>
</div>
)

View file

@ -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<string, ReferenceData>()
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))
* <ReactMarkdown components={{ a: LinkComponent }}>...</ReactMarkdown>
*/
export function createCompactReferenceLinkComponent(
onReferenceClick: (type: ReferenceType, id: string) => void
) {
const CompactReferenceLinkComponent = ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
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 (
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onReferenceClick(type, id)
}}
className="text-primary hover:underline cursor-pointer inline font-medium"
type="button"
>
{children}
</button>
)
}
// Regular link - open in new tab
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props} className="text-primary hover:underline">
{children}
</a>
)
}
CompactReferenceLinkComponent.displayName = 'CompactReferenceLinkComponent'
return CompactReferenceLinkComponent
}
/**
* Legacy function for backward compatibility
* Converts old Link-based references to new click handler approach