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:
parent
a287d3b248
commit
933de75b73
3 changed files with 170 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue