Skip to content

Commit 588918a

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 089b613 commit 588918a

24 files changed

+773
-70
lines changed

packages/cozy-viewer/jest.config.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ module.exports = {
88
'\\.(png|gif|jpe?g|svg)$': '<rootDir>/test/__mocks__/fileMock.js',
99
'\\.styl$': 'identity-obj-proxy',
1010
'react-pdf/dist/esm/entry.webpack': 'react-pdf',
11+
'^cozy-client/src/(.*)$': '<rootDir>/node_modules/cozy-client/dist/$1',
1112
'^cozy-client$': '<rootDir>/node_modules/cozy-client/dist/index',
12-
'^cozy-client/dist/types$':
13-
'<rootDir>/node_modules/cozy-client/dist/types.js',
14-
'^cozy-ui$': '<rootDir>/node_modules/cozy-ui/$1'
13+
'^cozy-client/dist/(.*)$': '<rootDir>/node_modules/cozy-client/dist/$1',
14+
'^cozy-ui$': '<rootDir>/node_modules/cozy-ui/$1',
15+
'^cozy-flags$': '<rootDir>/test/__mocks__/cozyFlagsMock.js',
16+
'^cozy-intent$': '<rootDir>/test/__mocks__/cozy-intent.js',
17+
'^cozy-sharing$': '<rootDir>/test/__mocks__/cozy-sharing.js',
18+
'^cozy-harvest-lib/dist/components/KonnectorBlock$':
19+
'<rootDir>/test/__mocks__/cozy-harvest-lib.js'
1520
},
1621
transformIgnorePatterns: ['node_modules/(?!(cozy-ui|cozy-harvest-lib))'],
1722
transform: {

packages/cozy-viewer/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"@testing-library/react-hooks": "^3.2.1",
3030
"babel-plugin-inline-json-import": "0.3.2",
3131
"babel-preset-cozy-app": "^2.8.2",
32-
"cozy-client": "^57.0.0",
32+
"cozy-client": "^60.20.0",
3333
"cozy-device-helper": "2.0.0",
3434
"cozy-harvest-lib": "^35.1.10",
3535
"cozy-intent": "^2.30.1",
@@ -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
}

packages/cozy-viewer/src/Footer/FooterContent.jsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const useStyles = makeStyles(theme => ({
2727
}
2828
}))
2929

30-
const FooterContent = ({ toolbarRef, children }) => {
30+
const FooterContent = ({ toolbarRef, children, isDisplayChildrenDirectly }) => {
3131
const styles = useStyles()
3232
const { file, isPublic } = useViewer()
3333

@@ -50,12 +50,19 @@ const FooterContent = ({ toolbarRef, children }) => {
5050
portalProps={{ disablePortal: true }}
5151
settings={bottomSheetSettings}
5252
>
53-
<BottomSheetHeader
54-
className={cx('u-ph-1 u-pb-1', styles.bottomSheetHeader)}
55-
>
56-
{FooterActionButtonsWithFile}
57-
</BottomSheetHeader>
58-
<BottomSheetContent />
53+
{isDisplayChildrenDirectly ? (
54+
children
55+
) : (
56+
<>
57+
<BottomSheetHeader
58+
className={cx('u-ph-1 u-pb-1', styles.bottomSheetHeader)}
59+
>
60+
{FooterActionButtonsWithFile}
61+
</BottomSheetHeader>
62+
63+
<BottomSheetContent />
64+
</>
65+
)}
5966
</BottomSheet>
6067
)
6168
}
@@ -65,7 +72,8 @@ FooterContent.propTypes = {
6572
children: PropTypes.oneOfType([
6673
PropTypes.node,
6774
PropTypes.arrayOf(PropTypes.node)
68-
])
75+
]),
76+
isDisplayChildrenDirectly: PropTypes.bool
6977
}
7078

7179
export default FooterContent
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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 logger from 'cozy-logger'
10+
import Button from 'cozy-ui/transpiled/react/Buttons'
11+
import Icon from 'cozy-ui/transpiled/react/Icon'
12+
import IconButton from 'cozy-ui/transpiled/react/IconButton'
13+
import AssistantIcon from 'cozy-ui/transpiled/react/Icons/Assistant'
14+
import CopyIcon from 'cozy-ui/transpiled/react/Icons/Copy'
15+
import CrossMediumIcon from 'cozy-ui/transpiled/react/Icons/CrossMedium'
16+
import RefreshIcon from 'cozy-ui/transpiled/react/Icons/Refresh'
17+
import Paper from 'cozy-ui/transpiled/react/Paper'
18+
import Stack from 'cozy-ui/transpiled/react/Stack'
19+
import Typography from 'cozy-ui/transpiled/react/Typography'
20+
import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'
21+
import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n'
22+
23+
import { SUMMARY_SYSTEM_PROMPT, getSummaryUserPrompt } from './prompts'
24+
import styles from './styles.styl'
25+
import { getSummaryConfig, roughTokensEstimation } from '../../helpers'
26+
import { useViewer } from '../../providers/ViewerProvider'
27+
28+
const AIAssistantPanel = () => {
29+
const { t } = useI18n()
30+
const client = useClient()
31+
const { file, setIsOpenAiAssistant } = useViewer()
32+
const { showAlert } = useAlert()
33+
34+
const [isLoading, setIsLoading] = useState(true)
35+
const [summary, setSummary] = useState('')
36+
const [error, setError] = useState(null)
37+
38+
const location = useLocation()
39+
const navigate = useNavigate()
40+
41+
const handleClose = () => {
42+
setIsOpenAiAssistant(false)
43+
if (location?.state?.showAIAssistant) {
44+
navigate('..')
45+
}
46+
}
47+
48+
const summarizeFile = async ({ client, file, stream = false, model }) => {
49+
try {
50+
const fileBlob = await fetchBlobFileById(client, file?._id)
51+
52+
const textContent = await extractText(client, fileBlob, {
53+
name: file.name,
54+
mime: file.mime
55+
})
56+
57+
const summaryConfig = getSummaryConfig()
58+
if (
59+
summaryConfig?.maxTokens &&
60+
roughTokensEstimation(textContent) > summaryConfig.maxTokens
61+
) {
62+
throw new Error(
63+
`Text content exceeds maximum token limit (${summaryConfig.maxTokens} tokens)`
64+
)
65+
}
66+
67+
const messages = [
68+
{ role: 'system', content: SUMMARY_SYSTEM_PROMPT },
69+
{
70+
role: 'user',
71+
content: getSummaryUserPrompt(textContent)
72+
}
73+
]
74+
75+
const summaryResponse = await chatCompletion(client, messages, {
76+
stream,
77+
model
78+
})
79+
80+
return summaryResponse
81+
} catch (error) {
82+
logger.error('Error in summarizeFile:', error)
83+
throw error
84+
}
85+
}
86+
87+
const fetchSummary = useCallback(async () => {
88+
if (!file) return
89+
90+
setIsLoading(true)
91+
setError(null)
92+
try {
93+
const response = await summarizeFile({ client, file, stream: false })
94+
if (response && response.content) {
95+
setSummary(response.content)
96+
} else if (response && response.choices && response.choices[0]) {
97+
setSummary(response.choices[0].message.content)
98+
}
99+
} catch (err) {
100+
setError(err.message || 'Failed to generate summary')
101+
} finally {
102+
setIsLoading(false)
103+
}
104+
}, [client, file])
105+
106+
const handleRefresh = () => {
107+
fetchSummary()
108+
}
109+
110+
const handleCopy = () => {
111+
if (summary) {
112+
navigator.clipboard.writeText(summary)
113+
showAlert({ message: t('Viewer.ai.copied'), severity: 'success' })
114+
}
115+
}
116+
117+
useEffect(() => {
118+
fetchSummary()
119+
}, [fetchSummary])
120+
121+
return (
122+
<>
123+
<Stack spacing="s" className={cx('u-flex u-flex-column u-h-100')}>
124+
<Paper
125+
className={cx({
126+
'u-flex-grow-1': !isLoading
127+
})}
128+
elevation={2}
129+
square
130+
>
131+
<div className="u-flex u-flex-items-center u-flex-justify-between u-h-3 u-ph-1 u-flex-shrink-0">
132+
<Typography variant="h4">
133+
<Icon icon={AssistantIcon} /> {t('Viewer.ai.panelTitle')}
134+
</Typography>
135+
<IconButton aria-label="Close AI Assistant" onClick={handleClose}>
136+
<Icon icon={CrossMediumIcon} />
137+
</IconButton>
138+
</div>
139+
{!isLoading && (
140+
<Stack spacing="s" className="u-ph-1">
141+
<div>
142+
<div className="u-flex u-flex-items-center u-flex-justify-between u-mb-1">
143+
<Typography variant="subtitle1">
144+
{t('Viewer.ai.bodyText')}
145+
</Typography>
146+
<div className="u-flex">
147+
<IconButton size="small" onClick={handleRefresh}>
148+
<Icon icon={RefreshIcon} />
149+
</IconButton>
150+
{summary && (
151+
<IconButton size="small" onClick={handleCopy}>
152+
<Icon icon={CopyIcon} />
153+
</IconButton>
154+
)}
155+
</div>
156+
</div>
157+
<Typography className="u-mb-1">
158+
{error ? (
159+
<span style={{ color: 'var(--errorColor)' }}>{error}</span>
160+
) : (
161+
summary
162+
)}
163+
</Typography>
164+
{!isLoading && summary && (
165+
<Typography variant="caption" color="textSecondary">
166+
{t('Viewer.ai.footerText')}
167+
</Typography>
168+
)}
169+
</div>
170+
</Stack>
171+
)}
172+
</Paper>
173+
{isLoading ? (
174+
<>
175+
<div className={styles.loaderContainer}>
176+
<div className={styles.loaderBar} />
177+
</div>
178+
<div className="u-flex u-flex-items-center u-flex-justify-between u-ph-1">
179+
<Typography
180+
variant="body1"
181+
className="u-flex u-flex-items-center"
182+
>
183+
<Icon
184+
icon={AssistantIcon}
185+
color="var(--primaryColor)"
186+
className="u-mr-1"
187+
/>
188+
{t('Viewer.ai.loadingText')}
189+
</Typography>
190+
<Button
191+
size="small"
192+
variant="text"
193+
color="default"
194+
label={t('Viewer.ai.stop')}
195+
onClick={handleClose}
196+
/>
197+
</div>
198+
</>
199+
) : null}
200+
</Stack>
201+
</>
202+
)
203+
}
204+
205+
AIAssistantPanel.propTypes = {
206+
isLoading: PropTypes.bool,
207+
summary: PropTypes.string,
208+
onStop: PropTypes.func,
209+
onSend: PropTypes.func
210+
}
211+
212+
AIAssistantPanel.defaultProps = {
213+
isLoading: false,
214+
summary: ''
215+
}
216+
217+
export default AIAssistantPanel
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* AI prompts for document summarization
3+
* These will be externalized in the future
4+
*/
5+
export const SUMMARY_SYSTEM_PROMPT = `You are a concise and reliable text summarizer.
6+
7+
Your goal:
8+
- Produce a clear and accurate summary of the provided content
9+
- Keep the original meaning and key information only
10+
- Remove redundancy, examples, anecdotes, and minor details
11+
12+
Writing rules:
13+
- Keep the same language as the provided input. For example, if it's french, keep french
14+
- Be concise and use simple phrasing
15+
- Do not add new information
16+
- Do not guess what is not explicitly stated
17+
18+
Output:
19+
- A single coherent paragraph unless otherwise specified
20+
- Do not add any extra information or interpret anything beyond the explicit task`
21+
22+
/**
23+
* Generate user prompt for document summarization
24+
* @param {string} textContent - The text content to summarize
25+
* @returns {string} The formatted user prompt
26+
*/
27+
export const getSummaryUserPrompt = textContent => {
28+
return `Summarize the following content:\n\n${textContent}`
29+
}
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

0 commit comments

Comments
 (0)