|
1 |
| -'use client'; |
| 1 | +import { useState } from 'react'; |
2 | 2 |
|
3 |
| -import { Transition } from '@headlessui/react'; |
4 |
| -import { flatten, sortBy, uniqBy } from 'lodash'; |
5 |
| -import React, { useRef } from 'react'; |
6 |
| -import { useMemo, useState } from 'react'; |
7 |
| - |
8 |
| -import { Document } from '@/cohere-client'; |
9 |
| -import { CitationDocument } from '@/components/Citations/CitationDocument'; |
10 |
| -import { IconButton } from '@/components/IconButton'; |
11 |
| -import { Text } from '@/components/Shared/Text'; |
12 |
| -import { ReservedClasses } from '@/constants'; |
13 |
| -import { CitationStyles, useCalculateCitationTranslateY } from '@/hooks/citations'; |
| 3 | +import { DocumentIcon, Icon, Text } from '@/components/Shared'; |
| 4 | +import { TOOL_ID_TO_DISPLAY_INFO, TOOL_WEB_SEARCH_ID } from '@/constants'; |
14 | 5 | import { useCitationsStore } from '@/stores';
|
15 |
| -import { cn, pluralize } from '@/utils'; |
| 6 | +import { getSafeUrl, getWebDomain } from '@/utils'; |
| 7 | + |
| 8 | +const getWebSourceName = (toolId?: string | null) => { |
| 9 | + if (!toolId) { |
| 10 | + return ''; |
| 11 | + } else if (toolId === TOOL_WEB_SEARCH_ID) { |
| 12 | + return 'from the web'; |
| 13 | + } |
| 14 | + return `from ${toolId}`; |
| 15 | +}; |
16 | 16 |
|
17 | 17 | type Props = {
|
18 | 18 | generationId: string;
|
19 |
| - message: string; |
20 |
| - isLastStreamed?: boolean; |
21 |
| - styles?: CitationStyles; |
22 |
| - className?: string; |
| 19 | + citationKey: string; |
23 | 20 | };
|
24 | 21 |
|
25 |
| -export const DEFAULT_NUM_VISIBLE_DOCS = 3; |
26 |
| - |
27 |
| -/** |
28 |
| - * Placeholder component for a citation. |
29 |
| - * This component is in charge of rendering the citations for a given generation. |
30 |
| - * @params {string} generationId - the id of the generation |
31 |
| - * @params {string} message - the message that was sent |
32 |
| - * @params {boolean} isLastStreamed - if the citation is for the last streamed message |
33 |
| - * @params {number} styles - top and bottom styling, depending on the associated message row |
34 |
| - * @params {string} className - additional class names to add to the citation |
35 |
| - */ |
36 |
| -export const Citation = React.forwardRef<HTMLDivElement, Props>(function CitationInternal( |
37 |
| - { generationId, message, className = '', styles, isLastStreamed = false }, |
38 |
| - ref |
39 |
| -) { |
40 |
| - const { |
41 |
| - citations: { citationReferences, selectedCitation, hoveredGenerationId }, |
42 |
| - hoverCitation, |
43 |
| - } = useCitationsStore(); |
44 |
| - |
45 |
| - const containerRef = useRef<HTMLDivElement>(null); |
46 |
| - const [keyword, setKeyword] = useState(''); |
47 |
| - const isSelected = selectedCitation?.generationId === generationId; |
48 |
| - const isSomeSelected = !!selectedCitation?.generationId; |
49 |
| - const isHovered = hoveredGenerationId === generationId; |
50 |
| - const [isAllDocsVisible, setIsAllDocsVisible] = useState(false); |
51 |
| - |
52 |
| - const startEndKeyToDocs = citationReferences[generationId]; |
53 |
| - const documents: Document[] = useMemo(() => { |
54 |
| - if (!startEndKeyToDocs) { |
55 |
| - return []; |
56 |
| - } |
57 |
| - |
58 |
| - if (selectedCitation && generationId === selectedCitation.generationId) { |
59 |
| - setKeyword(message.slice(Number(selectedCitation.start), Number(selectedCitation.end))); |
60 |
| - return startEndKeyToDocs[`${selectedCitation.start}-${selectedCitation.end}`]; |
61 |
| - } else { |
62 |
| - const firstCitedTextKey = Object.keys(startEndKeyToDocs)[0]; |
63 |
| - const [start, end] = firstCitedTextKey.split('-'); |
64 |
| - setKeyword(message.slice(Number(start), Number(end))); |
65 |
| - return startEndKeyToDocs[firstCitedTextKey]; |
66 |
| - } |
67 |
| - }, [startEndKeyToDocs, selectedCitation, generationId, message]); |
68 |
| - |
69 |
| - const translateY = useCalculateCitationTranslateY({ |
70 |
| - generationId, |
71 |
| - citationRef: containerRef, |
72 |
| - }); |
73 |
| - |
74 |
| - if (!startEndKeyToDocs || documents.length === 0 || (!isSelected && !!selectedCitation)) { |
75 |
| - return null; |
76 |
| - } |
77 |
| - |
78 |
| - const highlightedDocumentIds = documents |
79 |
| - .slice(0, DEFAULT_NUM_VISIBLE_DOCS) |
80 |
| - .map((doc) => doc.document_id); |
81 |
| - |
82 |
| - const uniqueDocuments = sortBy( |
83 |
| - uniqBy(flatten(Object.values(startEndKeyToDocs)), 'document_id'), |
84 |
| - 'document_id' |
85 |
| - ); |
86 |
| - const uniqueDocumentsUrls = uniqBy(uniqueDocuments, 'url'); |
87 |
| - |
88 |
| - const handleMouseEnter = () => { |
89 |
| - hoverCitation(generationId); |
90 |
| - }; |
91 |
| - |
92 |
| - const handleMouseLeave = () => { |
93 |
| - hoverCitation(null); |
94 |
| - }; |
95 |
| - |
96 |
| - const handleToggleAllDocsVisible = () => { |
97 |
| - setIsAllDocsVisible(!isAllDocsVisible); |
98 |
| - }; |
| 22 | +export const Citation: React.FC<Props> = ({ generationId, citationKey }) => { |
| 23 | + const [selectedIndex, setSelectedIndex] = useState(0); |
| 24 | + const { citations } = useCitationsStore(); |
| 25 | + const citationsMap = citations.citationReferences[generationId]; |
| 26 | + const documents = citationsMap[citationKey]; |
| 27 | + const document = documents[selectedIndex]; |
| 28 | + const safeUrl = document.url ? getSafeUrl(document.url) : undefined; |
99 | 29 |
|
100 | 30 | return (
|
101 |
| - <Transition |
102 |
| - as="div" |
103 |
| - id={generationId ? `citation-${generationId}` : undefined} |
104 |
| - show={true} |
105 |
| - enter="delay-300 duration-300 ease-out transition-[transform,opacity]" // delay to wait for the citation side panel to open |
106 |
| - enterFrom="translate-x-2 opacity-0" |
107 |
| - enterTo="translate-x-0 opacity-100" |
108 |
| - leave="duration-300 ease-in transition-[transform,opacity]" |
109 |
| - leaveFrom="translate-x-0 opacity-100" |
110 |
| - leaveTo="translate-x-2 opacity-0" |
111 |
| - ref={containerRef} |
112 |
| - style={{ |
113 |
| - ...styles, |
114 |
| - ...(translateY !== 0 && isSelected |
115 |
| - ? { |
116 |
| - '--selectedTranslateY': `${translateY}px`, |
117 |
| - } |
118 |
| - : {}), |
119 |
| - }} |
120 |
| - className={cn( |
121 |
| - 'w-[260px] max-w-[260px] rounded', |
122 |
| - 'bg-marble-1000 transition-[transform,top] duration-300 ease-in-out dark:bg-volcanic-200', |
123 |
| - 'md:absolute', |
124 |
| - { |
125 |
| - 'md:-translate-x-1': isHovered, |
126 |
| - 'md:z-selected-citation': isSelected || isAllDocsVisible || isHovered, |
127 |
| - 'md:translate-y-[var(--selectedTranslateY)] md:shadow-lg': isSelected, |
128 |
| - } |
129 |
| - )} |
130 |
| - > |
131 |
| - <div |
132 |
| - ref={ref} |
133 |
| - className={cn( |
134 |
| - ReservedClasses.CITATION, |
135 |
| - 'rounded md:p-3', |
136 |
| - 'transition-[colors,opacity] duration-300 ease-in-out', |
137 |
| - { |
138 |
| - 'opacity-60 dark:opacity-100': |
139 |
| - !isSelected && !isHovered && (!isLastStreamed || isSomeSelected), |
140 |
| - 'opacity-90 dark:opacity-100': !isSelected && isHovered, |
141 |
| - 'bg-mushroom-400/[0.08 dark:bg-volcanic-200': !isSelected, |
142 |
| - 'bg-coral-700/[0.08] dark:bg-volcanic-200': isSelected, |
143 |
| - 'flex flex-col gap-y-4 lg:gap-y-6': isSelected, |
144 |
| - }, |
145 |
| - className |
146 |
| - )} |
147 |
| - onMouseEnter={handleMouseEnter} |
148 |
| - onMouseLeave={handleMouseLeave} |
149 |
| - > |
150 |
| - <Text className="text-coral-300 md:hidden dark:text-marble-950">{keyword}</Text> |
151 |
| - |
152 |
| - <div className={cn('mb-4 flex items-center justify-between', { hidden: isSelected })}> |
153 |
| - <Text as="span" styleAs="caption" className="text-volcanic-300 dark:text-marble-800"> |
154 |
| - {uniqueDocumentsUrls.length} {pluralize('reference', uniqueDocumentsUrls.length)} |
155 |
| - </Text> |
156 |
| - {uniqueDocumentsUrls.length > DEFAULT_NUM_VISIBLE_DOCS && ( |
157 |
| - <IconButton |
158 |
| - className={cn( |
159 |
| - 'h-4 w-4 fill-volcanic-300 transition delay-75 duration-200 ease-in-out dark:fill-marble-800', |
160 |
| - { |
161 |
| - 'rotate-180': isAllDocsVisible, |
162 |
| - } |
163 |
| - )} |
164 |
| - onClick={handleToggleAllDocsVisible} |
165 |
| - iconName="chevron-down" |
166 |
| - /> |
167 |
| - )} |
| 31 | + <div className="space-y-4"> |
| 32 | + <header className="flex items-center justify-between"> |
| 33 | + <div className="flex gap-2"> |
| 34 | + <div className="grid size-8 place-items-center rounded bg-white dark:bg-volcanic-150"> |
| 35 | + {document.url ? ( |
| 36 | + <a href={safeUrl} target="_blank" data-connectorid={document.tool_name}> |
| 37 | + <DocumentIcon url={safeUrl} /> |
| 38 | + </a> |
| 39 | + ) : document.tool_name ? ( |
| 40 | + <Icon name={TOOL_ID_TO_DISPLAY_INFO[document.tool_name].icon} /> |
| 41 | + ) : ( |
| 42 | + <Icon name="file" /> |
| 43 | + )} |
| 44 | + </div> |
| 45 | + <div> |
| 46 | + {document.url ? ( |
| 47 | + <> |
| 48 | + <Text styleAs="p-xs" className="uppercase dark:text-marble-800"> |
| 49 | + {getWebDomain(safeUrl) + ' ' + getWebSourceName(document.tool_name)} |
| 50 | + </Text> |
| 51 | + <Text styleAs="p-sm" className="uppercase dark:text-marble-950"> |
| 52 | + {document.title || 'Untitled'} |
| 53 | + </Text> |
| 54 | + </> |
| 55 | + ) : ( |
| 56 | + <> |
| 57 | + <Text styleAs="p-xs" className="uppercase dark:text-marble-800"> |
| 58 | + Tool |
| 59 | + </Text> |
| 60 | + <Text styleAs="p-sm" className="uppercase dark:text-marble-950"> |
| 61 | + {document.tool_name} |
| 62 | + </Text> |
| 63 | + </> |
| 64 | + )} |
| 65 | + </div> |
168 | 66 | </div>
|
169 |
| - |
170 |
| - <div className="flex w-full flex-col gap-y-4"> |
171 |
| - {isSelected |
172 |
| - ? uniqueDocuments.map((doc) => { |
173 |
| - const isVisible = highlightedDocumentIds.includes(doc.document_id); |
174 |
| - |
175 |
| - if (!isVisible) { |
176 |
| - return null; |
177 |
| - } |
178 |
| - |
179 |
| - return ( |
180 |
| - <CitationDocument |
181 |
| - key={doc.document_id} |
182 |
| - isExpandable={isSelected} |
183 |
| - document={doc} |
184 |
| - keyword={keyword} |
185 |
| - /> |
186 |
| - ); |
187 |
| - }) |
188 |
| - : uniqueDocumentsUrls.map((doc, index) => { |
189 |
| - const isVisible = isAllDocsVisible || index < DEFAULT_NUM_VISIBLE_DOCS; |
190 |
| - |
191 |
| - if (!isVisible) { |
192 |
| - return null; |
193 |
| - } |
194 |
| - |
195 |
| - return ( |
196 |
| - <CitationDocument |
197 |
| - key={doc.url} |
198 |
| - isExpandable={isSelected} |
199 |
| - document={doc} |
200 |
| - keyword={keyword} |
201 |
| - /> |
202 |
| - ); |
203 |
| - })} |
204 |
| - </div> |
205 |
| - </div> |
206 |
| - </Transition> |
| 67 | + {documents.length > 1 && ( |
| 68 | + <div className="flex flex-shrink-0 items-center"> |
| 69 | + <button |
| 70 | + className="py-[3px] pr-2" |
| 71 | + onClick={() => |
| 72 | + setSelectedIndex((prev) => (prev - 1 + documents.length) % documents.length) |
| 73 | + } |
| 74 | + > |
| 75 | + <Icon name="chevron-left" /> |
| 76 | + </button> |
| 77 | + <Text className="text-p-sm"> |
| 78 | + {selectedIndex + 1} of {documents.length} |
| 79 | + </Text> |
| 80 | + <button |
| 81 | + className="py-[3px] pl-2" |
| 82 | + onClick={() => setSelectedIndex((prev) => (prev + 1) % documents.length)} |
| 83 | + > |
| 84 | + <Icon name="chevron-right" /> |
| 85 | + </button> |
| 86 | + </div> |
| 87 | + )} |
| 88 | + </header> |
| 89 | + <article className="max-h-64 overflow-y-auto"> |
| 90 | + <Text className="font-variable">{document.text}</Text> |
| 91 | + </article> |
| 92 | + </div> |
207 | 93 | );
|
208 |
| -}); |
| 94 | +}; |
0 commit comments