Skip to content

Commit 11ac343

Browse files
lethemanhlethemanh
authored andcommitted
feat: Implement summarization by AI ✨
BREAKING CHANGE: You need to add react-router-dom >= 6.14.2 as dep in your app ➕
1 parent 364fcdd commit 11ac343

File tree

18 files changed

+544
-17
lines changed

18 files changed

+544
-17
lines changed

packages/cozy-viewer/jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ module.exports = {
1111
'^cozy-client$': '<rootDir>/node_modules/cozy-client/dist/index',
1212
'^cozy-client/dist/types$':
1313
'<rootDir>/node_modules/cozy-client/dist/types.js',
14-
'^cozy-ui$': '<rootDir>/node_modules/cozy-ui/$1'
14+
'^cozy-ui$': '<rootDir>/node_modules/cozy-ui/$1',
15+
'^cozy-flags$': '<rootDir>/test/__mocks__/cozyFlagsMock.js'
1516
},
1617
transformIgnorePatterns: ['node_modules/(?!(cozy-ui|cozy-harvest-lib))'],
1718
transform: {

packages/cozy-viewer/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"jest-environment-jsdom": "26.6.2",
4444
"prop-types": "15.8.1",
4545
"react-dom": "16.12.0",
46+
"react-router-dom": "^6.14.2",
4647
"react-test-renderer": "16.12.0",
4748
"stylus": "0.63.0",
4849
"typescript": "5.5.2"
@@ -64,6 +65,7 @@
6465
"cozy-ui": ">=126.0.0",
6566
"cozy-ui-plus": ">=1.2.0",
6667
"react": ">=16.12.0",
67-
"react-dom": ">=16.12.0"
68+
"react-dom": ">=16.12.0",
69+
"react-router-dom": ">=6.14.2"
6870
}
6971
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import cx from 'classnames'
2+
import PropTypes from 'prop-types'
3+
import React, { useCallback, useState, useEffect } from 'react'
4+
import { useLocation, useNavigate } from 'react-router-dom'
5+
6+
import { useClient } from 'cozy-client'
7+
import { extractText, chatCompletion } from 'cozy-client/dist/models/ai'
8+
import { fetchBlobFileById } from 'cozy-client/dist/models/file'
9+
import Button from 'cozy-ui/transpiled/react/Buttons'
10+
import Icon from 'cozy-ui/transpiled/react/Icon'
11+
import IconButton from 'cozy-ui/transpiled/react/IconButton'
12+
import AssistantIcon from 'cozy-ui/transpiled/react/Icons/Assistant'
13+
import CopyIcon from 'cozy-ui/transpiled/react/Icons/Copy'
14+
import CrossMediumIcon from 'cozy-ui/transpiled/react/Icons/CrossMedium'
15+
import RefreshIcon from 'cozy-ui/transpiled/react/Icons/Refresh'
16+
import Paper from 'cozy-ui/transpiled/react/Paper'
17+
import Stack from 'cozy-ui/transpiled/react/Stack'
18+
import Typography from 'cozy-ui/transpiled/react/Typography'
19+
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
20+
import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n'
21+
22+
import styles from './styles.styl'
23+
import { useViewer } from '../../providers/ViewerProvider'
24+
25+
const AIAssistantPanel = () => {
26+
const { t } = useI18n()
27+
const client = useClient()
28+
const { file, setIsOpenAiAssistant } = useViewer()
29+
const { showAlert } = useAlert()
30+
31+
const [isLoading, setIsLoading] = useState(true)
32+
const [summary, setSummary] = useState('')
33+
const [error, setError] = useState(null)
34+
35+
const location = useLocation()
36+
const navigate = useNavigate()
37+
38+
const handleClose = () => {
39+
setIsOpenAiAssistant(false)
40+
if (location?.state?.showAIAssistant) {
41+
navigate('..')
42+
}
43+
}
44+
45+
const summarizeFile = async ({
46+
client,
47+
file,
48+
stream = false,
49+
model
50+
}) => {
51+
try {
52+
const fileBlob = await fetchBlobFileById(client, file?._id)
53+
54+
const textContent = await extractText(client, fileBlob, {
55+
name: file.name,
56+
mime: file.mime || file.class
57+
})
58+
59+
const messages = [
60+
{ role: 'system', content: 'You are an expert in summarizing documents.' },
61+
{
62+
role: 'user',
63+
content: `Please provide a concise summary of the following document:\n\n${textContent}`
64+
}
65+
]
66+
67+
const summaryResponse = await chatCompletion(client, messages, {
68+
stream,
69+
model
70+
})
71+
72+
return summaryResponse
73+
} catch (error) {
74+
console.error('Error in summarizeFile:', error)
75+
throw error
76+
}
77+
}
78+
79+
const fetchSummary = useCallback(async () => {
80+
if (!file) return
81+
82+
setIsLoading(true)
83+
setError(null)
84+
try {
85+
const response = await summarizeFile({ client, file, stream: false })
86+
if (response && response.content) {
87+
setSummary(response.content)
88+
} else if (response && response.choices && response.choices[0]) {
89+
setSummary(response.choices[0].message.content)
90+
}
91+
} catch (err) {
92+
setError(err.message || 'Failed to generate summary')
93+
} finally {
94+
setIsLoading(false)
95+
}
96+
}, [client, file, t])
97+
98+
const handleRefresh = () => {
99+
fetchSummary()
100+
}
101+
102+
const handleCopy = () => {
103+
if (summary) {
104+
navigator.clipboard.writeText(summary)
105+
showAlert({ message: t('Viewer.ai.copied'), severity: 'success' })
106+
}
107+
}
108+
109+
useEffect(() => {
110+
fetchSummary()
111+
}, [fetchSummary])
112+
113+
return (
114+
<>
115+
<Stack spacing="s" className={cx('u-flex u-flex-column u-h-100')}>
116+
<Paper
117+
className={cx({
118+
'u-flex-grow-1': !isLoading
119+
})}
120+
elevation={2}
121+
square
122+
>
123+
<div className="u-flex u-flex-items-center u-flex-justify-between u-h-3 u-ph-1 u-flex-shrink-0">
124+
<Typography variant="h4">
125+
<Icon icon={AssistantIcon} /> {t('Viewer.ai.panelTitle')}
126+
</Typography>
127+
<IconButton aria-label="Close AI Assistant" onClick={handleClose}>
128+
<Icon icon={CrossMediumIcon} />
129+
</IconButton>
130+
</div>
131+
{!isLoading && (
132+
<Stack spacing="s" className="u-ph-1">
133+
<div>
134+
<div className="u-flex u-flex-items-center u-flex-justify-between u-mb-1">
135+
<Typography variant="subtitle1">{t('Viewer.ai.bodyText')}</Typography>
136+
<div className="u-flex">
137+
<IconButton size="small" onClick={handleRefresh}>
138+
<Icon icon={RefreshIcon} />
139+
</IconButton>
140+
{summary && (
141+
<IconButton size="small" onClick={handleCopy}>
142+
<Icon icon={CopyIcon} />
143+
</IconButton>
144+
)}
145+
</div>
146+
</div>
147+
<Typography className="u-mb-1">
148+
{error ? (
149+
<span style={{ color: 'var(--errorColor)' }}>{error}</span>
150+
) : (
151+
summary
152+
)}
153+
</Typography>
154+
{!isLoading && summary && (
155+
<Typography variant="caption" color="textSecondary">
156+
{t('Viewer.ai.footerText')}
157+
</Typography>
158+
)}
159+
</div>
160+
</Stack>
161+
)}
162+
</Paper>
163+
{isLoading ? (
164+
<>
165+
<div className={styles.loaderContainer}>
166+
<div className={styles.loaderBar} />
167+
</div>
168+
<div className="u-flex u-flex-items-center u-flex-justify-between u-ph-1">
169+
<Typography
170+
variant="body1"
171+
className="u-flex u-flex-items-center"
172+
>
173+
<Icon
174+
icon={AssistantIcon}
175+
color="var(--primaryColor)"
176+
className="u-mr-1"
177+
/>
178+
{t('Viewer.ai.loadingText')}
179+
</Typography>
180+
<Button
181+
size="small"
182+
variant="text"
183+
color="default"
184+
label={t('Viewer.ai.stop')}
185+
onClick={handleClose}
186+
/>
187+
</div>
188+
</>
189+
) : null}
190+
</Stack>
191+
</>
192+
)
193+
}
194+
195+
AIAssistantPanel.propTypes = {
196+
isLoading: PropTypes.bool,
197+
summary: PropTypes.string,
198+
onStop: PropTypes.func,
199+
onSend: PropTypes.func
200+
}
201+
202+
AIAssistantPanel.defaultProps = {
203+
isLoading: false,
204+
summary: ''
205+
}
206+
207+
export default AIAssistantPanel
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@keyframes aiLoaderSlide
2+
0%
3+
transform translateX(-100%)
4+
100%
5+
transform translateX(100%)
6+
7+
.loaderContainer
8+
position relative
9+
overflow hidden
10+
height 3px
11+
width 100%
12+
background-color var(--dividerColor)
13+
margin-top 0px
14+
15+
.loaderBar
16+
position absolute
17+
top 0
18+
left 0
19+
height 100%
20+
width 100%
21+
background linear-gradient(90deg, #00B8D4 0%, #667EEA 25%, #F093FB 50%, #F5576C 75%, #FDB813 100%)
22+
animation aiLoaderSlide 2s linear infinite

packages/cozy-viewer/src/ViewerInformationsWrapper.jsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import PropTypes from 'prop-types'
2-
import React from 'react'
2+
import React, { useEffect } from 'react'
3+
import { useLocation } from 'react-router-dom'
34

45
import { useSetFlagshipUI } from 'cozy-ui/transpiled/react/hooks/useSetFlagshipUi/useSetFlagshipUI'
56
import { useCozyTheme } from 'cozy-ui/transpiled/react/providers/CozyTheme'
67
import { useTheme } from 'cozy-ui/transpiled/react/styles'
78

89
import FooterContent from './Footer/FooterContent'
10+
import AIAssistantPanel from './Panel/AI/AIAssistantPanel'
911
import PanelContent from './Panel/PanelContent'
1012
import Footer from './components/Footer'
1113
import InformationPanel from './components/InformationPanel'
14+
import { useViewer } from './providers/ViewerProvider'
1215

1316
const ViewerInformationsWrapper = ({
1417
disableFooter,
@@ -18,7 +21,15 @@ const ViewerInformationsWrapper = ({
1821
}) => {
1922
const theme = useTheme()
2023
const { isLight } = useCozyTheme()
24+
const { isOpenAiAssistant, setIsOpenAiAssistant } = useViewer()
2125
const sidebar = document.querySelector('[class*="sidebar"]')
26+
const location = useLocation()
27+
28+
useEffect(() => {
29+
if (location?.state?.showAIAssistant) {
30+
setIsOpenAiAssistant(true)
31+
}
32+
}, [location, setIsOpenAiAssistant])
2233

2334
useSetFlagshipUI(
2435
{
@@ -32,15 +43,23 @@ const ViewerInformationsWrapper = ({
3243

3344
return (
3445
<>
35-
{!disableFooter && (
36-
<Footer>
37-
<FooterContent toolbarRef={toolbarRef}>{children}</FooterContent>
38-
</Footer>
39-
)}
40-
{validForPanel && (
46+
{isOpenAiAssistant ? (
4147
<InformationPanel>
42-
<PanelContent />
48+
<AIAssistantPanel />
4349
</InformationPanel>
50+
) : (
51+
<>
52+
{!disableFooter && (
53+
<Footer>
54+
<FooterContent toolbarRef={toolbarRef}>{children}</FooterContent>
55+
</Footer>
56+
)}
57+
{validForPanel && (
58+
<InformationPanel>
59+
<PanelContent />
60+
</InformationPanel>
61+
)}
62+
</>
4463
)}
4564
</>
4665
)

packages/cozy-viewer/src/ViewersByFile/PdfJsViewer.jsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ToolbarButton from '../components/PdfToolbarButton'
1111
import ViewerSpinner from '../components/ViewerSpinner'
1212
import withFileUrl from '../hoc/withFileUrl'
1313
import { withViewerLocales } from '../hoc/withViewerLocales'
14+
import { ViewerContext } from '../providers/ViewerProvider'
1415

1516
export const MIN_SCALE = 0.25
1617
export const MAX_SCALE = 3
@@ -29,6 +30,8 @@ const makeInputPageStyle = nbPages => {
2930
}
3031

3132
export class PdfJsViewer extends Component {
33+
static contextType = ViewerContext
34+
3235
state = {
3336
totalPages: 1,
3437
scale: 1,
@@ -105,6 +108,11 @@ export class PdfJsViewer extends Component {
105108
parseInt(this.props.file.size, 10) <= MAX_SIZE_FILE,
106109
loaded: true
107110
})
111+
112+
// Update page count in ViewerContext for AI summary compatibility check
113+
if (this.context && this.context.setPdfPageCount) {
114+
this.context.setPdfPageCount(numPages)
115+
}
108116
}
109117

110118
onLoadError = error => {

0 commit comments

Comments
 (0)