From be35042b03382f39c37c83fbca6b872645c39bae Mon Sep 17 00:00:00 2001 From: lethemanh Date: Thu, 18 Dec 2025 10:55:35 +0700 Subject: [PATCH] feat: Skip extract file content with txt or markdown file :sparkles: --- .../src/Panel/AI/AIAssistantPanel.jsx | 194 +++++------------- .../cozy-viewer/src/Panel/AI/LoadingState.jsx | 43 ++++ .../cozy-viewer/src/Panel/AI/PanelHeader.jsx | 28 +++ .../src/Panel/AI/SummaryContent.jsx | 53 +++++ packages/cozy-viewer/src/Panel/AI/helpers.js | 65 ++++++ packages/cozy-viewer/src/helpers.js | 8 + .../src/helpers/ContentExtractionError.js | 7 + .../src/helpers/DocumentTooLargeError.js | 7 + packages/cozy-viewer/src/locales/en.json | 3 +- packages/cozy-viewer/src/locales/fr.json | 3 +- packages/cozy-viewer/src/locales/ru.json | 3 +- packages/cozy-viewer/src/locales/vi.json | 3 +- 12 files changed, 273 insertions(+), 144 deletions(-) create mode 100644 packages/cozy-viewer/src/Panel/AI/LoadingState.jsx create mode 100644 packages/cozy-viewer/src/Panel/AI/PanelHeader.jsx create mode 100644 packages/cozy-viewer/src/Panel/AI/SummaryContent.jsx create mode 100644 packages/cozy-viewer/src/Panel/AI/helpers.js create mode 100644 packages/cozy-viewer/src/helpers/ContentExtractionError.js create mode 100644 packages/cozy-viewer/src/helpers/DocumentTooLargeError.js diff --git a/packages/cozy-viewer/src/Panel/AI/AIAssistantPanel.jsx b/packages/cozy-viewer/src/Panel/AI/AIAssistantPanel.jsx index 1197a2ea8a..220516cb03 100644 --- a/packages/cozy-viewer/src/Panel/AI/AIAssistantPanel.jsx +++ b/packages/cozy-viewer/src/Panel/AI/AIAssistantPanel.jsx @@ -5,25 +5,23 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useI18n } from 'twake-i18n' import { useClient } from 'cozy-client' -import { extractText, chatCompletion } from 'cozy-client/dist/models/ai' +import { chatCompletion } from 'cozy-client/dist/models/ai' import { fetchBlobFileById } from 'cozy-client/dist/models/file' import flag from 'cozy-flags' import logger from 'cozy-logger' -import Button from 'cozy-ui/transpiled/react/Buttons' -import Icon from 'cozy-ui/transpiled/react/Icon' -import IconButton from 'cozy-ui/transpiled/react/IconButton' -import AssistantIcon from 'cozy-ui/transpiled/react/Icons/Assistant' -import CopyIcon from 'cozy-ui/transpiled/react/Icons/Copy' -import CrossMediumIcon from 'cozy-ui/transpiled/react/Icons/CrossMedium' -import RefreshIcon from 'cozy-ui/transpiled/react/Icons/Refresh' import Paper from 'cozy-ui/transpiled/react/Paper' import Stack from 'cozy-ui/transpiled/react/Stack' -import Typography from 'cozy-ui/transpiled/react/Typography' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' +import LoadingState from './LoadingState' +import PanelHeader from './PanelHeader' +import SummaryContent from './SummaryContent' +import { + extractFileContent, + validateContentSize, + getErrorMessage +} from './helpers' import { SUMMARY_SYSTEM_PROMPT, getSummaryUserPrompt } from './prompts' -import styles from './styles.styl' -import { roughTokensEstimation } from '../../helpers' import { useViewer } from '../../providers/ViewerProvider' const AIAssistantPanel = ({ className }) => { @@ -52,22 +50,10 @@ const AIAssistantPanel = ({ className }) => { const summarizeFile = async ({ client, file, stream = false, model }) => { try { const fileBlob = await fetchBlobFileById(client, file?._id) + const textContent = await extractFileContent(client, fileBlob, file) - const rawTextContent = await extractText(client, fileBlob, { - name: file.name, - mime: file.mime - }) - const textContent = rawTextContent ? JSON.stringify(rawTextContent) : '' - - const summaryConfig = flag('drive.summary') - if ( - summaryConfig?.maxTokens && - roughTokensEstimation(textContent) > summaryConfig.maxTokens - ) { - const error = new Error('DOCUMENT_TOO_LARGE') - error.code = 'DOCUMENT_TOO_LARGE' - throw error - } + const { maxTokens } = flag('drive.summary') ?? {} + validateContentSize(textContent, maxTokens) const messages = [ { role: 'system', content: SUMMARY_SYSTEM_PROMPT }, @@ -89,23 +75,22 @@ const AIAssistantPanel = ({ className }) => { } } - const persistedSummary = async ( - fileMetadata, - targetFileId, - summaryContent - ) => { - try { - await client - .collection('io.cozy.files') - .updateMetadataAttribute(targetFileId, { - ...fileMetadata, - description: summaryContent - }) - fetchedFileIdRef.current = targetFileId - } catch (error) { - logger.error('Error when persisting summary to file metadata:', error) - } - } + const persistedSummary = useCallback( + async (fileMetadata, targetFileId, summaryContent) => { + try { + await client + .collection('io.cozy.files') + .updateMetadataAttribute(targetFileId, { + ...fileMetadata, + description: summaryContent + }) + fetchedFileIdRef.current = targetFileId + } catch (error) { + logger.error('Error when persisting summary to file metadata:', error) + } + }, + [client] + ) useEffect(() => { activeFileIdRef.current = file?._id || null @@ -142,11 +127,7 @@ const AIAssistantPanel = ({ className }) => { await persistedSummary(fileMetadata, targetFileId, summaryContent) } catch (err) { if (activeFileIdRef.current === targetFileId) { - const errorMessage = - err.code === 'DOCUMENT_TOO_LARGE' - ? t('Viewer.ai.error.documentTooLarge') - : t('Viewer.ai.error.summary') - setError(errorMessage) + setError(getErrorMessage(err, t)) } } finally { if (inFlightFileIdRef.current === targetFileId) { @@ -157,7 +138,7 @@ const AIAssistantPanel = ({ className }) => { } } }, - [client, file, t] + [client, file, persistedSummary, t] ) const handleRefresh = () => { @@ -177,102 +158,35 @@ const AIAssistantPanel = ({ className }) => { }, [fetchSummary]) return ( - <> - + - -
- - {t('Viewer.ai.panelTitle')} - - - - -
- {!isLoading && ( - -
-
- - {t('Viewer.ai.bodyText')} - -
- - - - {summary && ( - - - - )} -
-
- - {error ? ( - {error} - ) : ( - summary - )} - - {!isLoading && summary && ( - - {t('Viewer.ai.footerText')} - - )} -
-
- )} -
- {isLoading ? ( - <> -
-
-
-
- - - {t('Viewer.ai.loadingText')} - -
- - ) : null} - - + + {!isLoading && ( + + )} + + {isLoading && } + ) } AIAssistantPanel.propTypes = { - isLoading: PropTypes.bool, - summary: PropTypes.string, - onStop: PropTypes.func, - onSend: PropTypes.func -} - -AIAssistantPanel.defaultProps = { - isLoading: false, - summary: '' + className: PropTypes.string } export default AIAssistantPanel diff --git a/packages/cozy-viewer/src/Panel/AI/LoadingState.jsx b/packages/cozy-viewer/src/Panel/AI/LoadingState.jsx new file mode 100644 index 0000000000..8349069f66 --- /dev/null +++ b/packages/cozy-viewer/src/Panel/AI/LoadingState.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types' +import React from 'react' + +import Button from 'cozy-ui/transpiled/react/Buttons' +import Icon from 'cozy-ui/transpiled/react/Icon' +import AssistantIcon from 'cozy-ui/transpiled/react/Icons/Assistant' +import Typography from 'cozy-ui/transpiled/react/Typography' + +import styles from './styles.styl' + +const LoadingState = ({ onStop, t }) => { + return ( + <> +
+
+
+
+ + + {t('Viewer.ai.loadingText')} + +
+ + ) +} + +LoadingState.propTypes = { + onStop: PropTypes.func.isRequired, + t: PropTypes.func.isRequired +} + +export default LoadingState diff --git a/packages/cozy-viewer/src/Panel/AI/PanelHeader.jsx b/packages/cozy-viewer/src/Panel/AI/PanelHeader.jsx new file mode 100644 index 0000000000..d06da32a68 --- /dev/null +++ b/packages/cozy-viewer/src/Panel/AI/PanelHeader.jsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types' +import React from 'react' + +import Icon from 'cozy-ui/transpiled/react/Icon' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import AssistantIcon from 'cozy-ui/transpiled/react/Icons/Assistant' +import CrossMediumIcon from 'cozy-ui/transpiled/react/Icons/CrossMedium' +import Typography from 'cozy-ui/transpiled/react/Typography' + +const PanelHeader = ({ onClose, t }) => { + return ( +
+ + {t('Viewer.ai.panelTitle')} + + + + +
+ ) +} + +PanelHeader.propTypes = { + onClose: PropTypes.func.isRequired, + t: PropTypes.func.isRequired +} + +export default PanelHeader diff --git a/packages/cozy-viewer/src/Panel/AI/SummaryContent.jsx b/packages/cozy-viewer/src/Panel/AI/SummaryContent.jsx new file mode 100644 index 0000000000..27efc93ac3 --- /dev/null +++ b/packages/cozy-viewer/src/Panel/AI/SummaryContent.jsx @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types' +import React from 'react' + +import Icon from 'cozy-ui/transpiled/react/Icon' +import IconButton from 'cozy-ui/transpiled/react/IconButton' +import CopyIcon from 'cozy-ui/transpiled/react/Icons/Copy' +import RefreshIcon from 'cozy-ui/transpiled/react/Icons/Refresh' +import Stack from 'cozy-ui/transpiled/react/Stack' +import Typography from 'cozy-ui/transpiled/react/Typography' + +const SummaryContent = ({ summary, error, onRefresh, onCopy, t }) => { + return ( + +
+
+ {t('Viewer.ai.bodyText')} +
+ + + + {summary && ( + + + + )} +
+
+ + {error ? ( + {error} + ) : ( + summary + )} + + {summary && ( + + {t('Viewer.ai.footerText')} + + )} +
+
+ ) +} + +SummaryContent.propTypes = { + summary: PropTypes.string, + error: PropTypes.string, + onRefresh: PropTypes.func.isRequired, + onCopy: PropTypes.func.isRequired, + t: PropTypes.func.isRequired +} + +export default SummaryContent diff --git a/packages/cozy-viewer/src/Panel/AI/helpers.js b/packages/cozy-viewer/src/Panel/AI/helpers.js new file mode 100644 index 0000000000..2c61ada5e0 --- /dev/null +++ b/packages/cozy-viewer/src/Panel/AI/helpers.js @@ -0,0 +1,65 @@ +import { extractText } from 'cozy-client/dist/models/ai' + +import { roughTokensEstimation, isTextMimeType } from '../../helpers' +import { ContentExtractionError } from '../../helpers/ContentExtractionError' +import { DocumentTooLargeError } from '../../helpers/DocumentTooLargeError' + +/** + * Extracts content from a file blob + * For text-based files, reads content directly. For other files, uses AI extraction. + * @param {object} client - Cozy client instance + * @param {Blob} fileBlob - File blob to extract content from + * @param {object} file - File metadata object with mime type and name + * @returns {Promise} JSON stringified content + * @throws {ContentExtractionError} If content extraction fails or returns empty content + */ +export const extractFileContent = async (client, fileBlob, file) => { + let content + + if (isTextMimeType(file.mime)) { + content = await fileBlob.text() + } else { + content = await extractText(client, fileBlob, { + name: file.name, + mime: file.mime + }) + } + + if (!content || content.trim().length === 0) { + throw new ContentExtractionError() + } + + return JSON.stringify(content) +} + +/** + * Validates that content size does not exceed the maximum token limit + * @param {string} textContent - Content to validate + * @param {number} maxTokens - Maximum number of tokens allowed + * @throws {DocumentTooLargeError} If content exceeds the token limit + */ +export const validateContentSize = (textContent, maxTokens) => { + if (!maxTokens) return + + const tokens = roughTokensEstimation(textContent) + + if (tokens > maxTokens) { + throw new DocumentTooLargeError() + } +} + +/** + * Gets the appropriate error message based on error code + * @param {Error} error - Error object with optional code property + * @param {Function} t - Translation function + * @returns {string} Translated error message + */ +export const getErrorMessage = (error, t) => { + const errorMap = { + DOCUMENT_TOO_LARGE: 'Viewer.ai.error.documentTooLarge', + CONTENT_EXTRACTION_FAILED: 'Viewer.ai.error.extractContent' + } + + const errorKey = errorMap[error.code] || 'Viewer.ai.error.summary' + return t(errorKey) +} diff --git a/packages/cozy-viewer/src/helpers.js b/packages/cozy-viewer/src/helpers.js index 82a6a03fbc..a4486aadf0 100644 --- a/packages/cozy-viewer/src/helpers.js +++ b/packages/cozy-viewer/src/helpers.js @@ -84,6 +84,14 @@ export const roughTokensEstimation = text => { return Math.ceil(text.length / 4) } +/** + * Check if a file is a text-based file type + * @param {string} mime - MIME type of the file + * @returns {boolean} Whether the file is a text file + */ +export const isTextMimeType = mime => + typeof mime === 'string' && mime.toLowerCase().startsWith('text/') + /** * Check if a file is compatible with AI summary feature * Compatible file types are defined in the drive.summary flag diff --git a/packages/cozy-viewer/src/helpers/ContentExtractionError.js b/packages/cozy-viewer/src/helpers/ContentExtractionError.js new file mode 100644 index 0000000000..4d19536cfe --- /dev/null +++ b/packages/cozy-viewer/src/helpers/ContentExtractionError.js @@ -0,0 +1,7 @@ +export class ContentExtractionError extends Error { + constructor() { + super('CONTENT_EXTRACTION_FAILED') + this.code = 'CONTENT_EXTRACTION_FAILED' + this.name = 'ContentExtractionError' + } +} diff --git a/packages/cozy-viewer/src/helpers/DocumentTooLargeError.js b/packages/cozy-viewer/src/helpers/DocumentTooLargeError.js new file mode 100644 index 0000000000..9e7a7c5049 --- /dev/null +++ b/packages/cozy-viewer/src/helpers/DocumentTooLargeError.js @@ -0,0 +1,7 @@ +export class DocumentTooLargeError extends Error { + constructor() { + super('DOCUMENT_TOO_LARGE') + this.code = 'DOCUMENT_TOO_LARGE' + this.name = 'DocumentTooLargeError' + } +} diff --git a/packages/cozy-viewer/src/locales/en.json b/packages/cozy-viewer/src/locales/en.json index 1c8275c3c0..4fb279cd84 100644 --- a/packages/cozy-viewer/src/locales/en.json +++ b/packages/cozy-viewer/src/locales/en.json @@ -86,7 +86,8 @@ "bodyText": "Summary", "error": { "summary": "Failed to generate summary. Please try again.", - "documentTooLarge": "This document is too large to summarize. Please try with a shorter document." + "documentTooLarge": "This document is too large to summarize. Please try with a shorter document.", + "extractContent": "Failed to extract content from file" }, "copied": "Summary copied to clipboard", "footerText": "This content is generated by AI and may contain errors.", diff --git a/packages/cozy-viewer/src/locales/fr.json b/packages/cozy-viewer/src/locales/fr.json index 72d16ce1f1..8dae0fca24 100644 --- a/packages/cozy-viewer/src/locales/fr.json +++ b/packages/cozy-viewer/src/locales/fr.json @@ -86,7 +86,8 @@ "bodyText": "Résumé", "error": { "summary": "Échec de la génération du résumé. Veuillez réessayer.", - "documentTooLarge": "Ce document est trop volumineux pour être résumé. Veuillez essayer avec un document plus court." + "documentTooLarge": "Ce document est trop volumineux pour être résumé. Veuillez essayer avec un document plus court.", + "extractContent": "Échec de l'extraction du contenu du fichier" }, "copied": "Résumé copié dans le presse-papier", "footerText": "Ce contenu est généré par AI et peut contenir des erreurs.", diff --git a/packages/cozy-viewer/src/locales/ru.json b/packages/cozy-viewer/src/locales/ru.json index 93fa14955b..56c31ed10f 100644 --- a/packages/cozy-viewer/src/locales/ru.json +++ b/packages/cozy-viewer/src/locales/ru.json @@ -86,7 +86,8 @@ "bodyText": "Резюме", "error": { "summary": "Не удалось создать резюме. Пожалуйста, попробуйте снова.", - "documentTooLarge": "Этот документ слишком большой для резюмирования. Пожалуйста, попробуйте с более коротким документом." + "documentTooLarge": "Этот документ слишком большой для резюмирования. Пожалуйста, попробуйте с более коротким документом.", + "extractContent": "Не удалось извлечь содержимое из файла" }, "copied": "Резюме скопировано в буфер обмена", "footerText": "Этот контент создан AI и может содержать ошибки.", diff --git a/packages/cozy-viewer/src/locales/vi.json b/packages/cozy-viewer/src/locales/vi.json index 51a5c0a07c..ab7452d5b0 100644 --- a/packages/cozy-viewer/src/locales/vi.json +++ b/packages/cozy-viewer/src/locales/vi.json @@ -86,7 +86,8 @@ "bodyText": "Tóm tắt", "error": { "summary": "Không thể tạo tóm tắt. Vui lòng thử lại.", - "documentTooLarge": "Tài liệu này quá lớn để tóm tắt. Vui lòng thử với tài liệu ngắn hơn." + "documentTooLarge": "Tài liệu này quá lớn để tóm tắt. Vui lòng thử với tài liệu ngắn hơn.", + "extractContent": "Không thể trích xuất nội dung từ tệp" }, "copied": "Đã sao chép tóm tắt vào khay nhớ tạm", "footerText": "Nội dung này được tạo bởi AI và có thể chứa lỗi.",