1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-08-16 06:03:35 +00:00

feat: file details ai (#7251)

Co-authored-by: 杨国璇 <ygx@Hello-word.local>
This commit is contained in:
杨国璇 2024-12-25 13:53:26 +08:00 committed by GitHub
parent b139c235f7
commit 9b8b7c9324
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 432 additions and 179 deletions

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<style type="text/css">
.st0{fill:#999999;}
</style>
<title>ai</title>
<g id="ai">
<g id="ai-assistant">
<path id="形状" class="st0" d="M16.1,0C24.9,0,32,6.8,32,15s-7.1,15-15.9,15c-0.1,0-0.3,0-0.6,0c-0.4,0-3.7,0.2-4.9,0.6
c-1,0.2-2.8,0.9-3.9,1.2C6.6,31.9,6.2,32,6,32c-0.5,0-0.9-0.4-1-1c-0.2-1.1-0.2-3,0.4-4.7C2.1,23.5,0,19.6,0,15
C0.1,6.8,7.2,0,16.1,0z M16,3C8.8,3,3,8.6,3,15.4c0,3.5,1.7,6.6,4.5,9.1l1.2,1.1c-0.4,0.8-0.7,1.3-0.8,1.7
c-0.2,0.5-0.3,1.3-0.2,1.7c0.8-0.4,2-1,2.9-1.2c1.7-0.4,4.2-0.6,5.4-0.6c5.7,0,13-5,13-11.8S23.2,3,16,3z M13.1,6.9
C10.3,6.9,8,9.1,8,11.8v10c0,0.1,0.1,0.3,0.3,0.3h2.8V11.9c0-1,0.9-1.9,1.9-1.9s1.9,0.9,1.9,1.9v2.3H12c-0.1,0-0.3,0-0.4,0.1
c-0.1,0.1-0.1,0.3-0.1,0.4v1.9c0,0.4,0.3,0.6,0.6,0.6H15v4.9h2.8c0.1,0,0.3-0.1,0.3-0.3v-10C18.1,9.1,15.9,6.9,13.1,6.9L13.1,6.9z
M22.4,11h-1.8c-0.4,0-0.6,0.2-0.6,0.6v9.8c0,0.4,0.2,0.6,0.6,0.6h1.8c0.4,0,0.6-0.2,0.6-0.6v-9.8C23,11.2,22.8,11,22.4,11z
M21.5,10c0.8,0,1.5-0.7,1.5-1.5S22.3,7,21.5,7S20,7.7,20,8.5S20.7,10,21.5,10z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -2,13 +2,13 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { gettext } from '../../utils/constants'; import { gettext } from '../../utils/constants';
import Lightbox from '@seafile/react-image-lightbox'; import Lightbox from '@seafile/react-image-lightbox';
import { useMetadataOperations } from '../../hooks/metadata-operation'; import { useMetadataAIOperations } from '../../hooks/metadata-ai-operation';
import { SYSTEM_FOLDERS } from '../../constants'; import { SYSTEM_FOLDERS } from '../../constants';
import '@seafile/react-image-lightbox/style.css'; import '@seafile/react-image-lightbox/style.css';
const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage, onRotateImage }) => { const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, closeImagePopup, moveToPrevImage, moveToNextImage, onDeleteImage, onRotateImage }) => {
const { onOCR } = useMetadataOperations(); const { enableOCR, enableMetadata, canModify, onOCR: onOCRAPI, OCRSuccessCallBack } = useMetadataAIOperations();
const downloadImage = useCallback((url) => { const downloadImage = useCallback((url) => {
location.href = url; location.href = url;
@ -33,6 +33,10 @@ const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, cl
} }
const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath)); const isSystemFolder = SYSTEM_FOLDERS.find(folderPath => mainImg.parentDir.startsWith(folderPath));
let onOCR = null;
if (enableOCR && enableMetadata && canModify && !isSystemFolder) {
onOCR = () => onOCRAPI({ parentDir: mainImg.parentDir, fileName: mainImg.name }, { success_callback: OCRSuccessCallBack });
}
return ( return (
<Lightbox <Lightbox
@ -57,7 +61,7 @@ const ImageDialog = ({ enableRotate: oldEnableRotate, imageItems, imageIndex, cl
onViewOriginal={onViewOriginal} onViewOriginal={onViewOriginal}
viewOriginalImageLabel={gettext('View original image')} viewOriginalImageLabel={gettext('View original image')}
onRotateImage={(onRotateImage && enableRotate) ? (angle) => onRotateImage(imageIndex, angle) : null} onRotateImage={(onRotateImage && enableRotate) ? (angle) => onRotateImage(imageIndex, angle) : null}
onOCR={onOCR && !isSystemFolder ? () => onOCR(mainImg.parentDir, mainImg.name) : null} onOCR={onOCR}
OCRLabel={gettext('OCR')} OCRLabel={gettext('OCR')}
/> />
); );

View File

@ -10,7 +10,7 @@ import DirDetails from './dir-details';
import FileDetails from './file-details'; import FileDetails from './file-details';
import ObjectUtils from '../../../metadata/utils/object-utils'; import ObjectUtils from '../../../metadata/utils/object-utils';
import { MetadataDetailsProvider } from '../../../metadata/hooks'; import { MetadataDetailsProvider } from '../../../metadata/hooks';
import Settings from '../../../metadata/components/metadata-details/settings'; import { Settings, AI } from '../../../metadata/components/metadata-details';
import { getDirentPath } from './utils'; import { getDirentPath } from './utils';
import './index.css'; import './index.css';
@ -129,6 +129,7 @@ class DirentDetails extends React.Component {
> >
<Detail> <Detail>
<Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={this.props.onClose} > <Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={this.props.onClose} >
<AI />
<Settings /> <Settings />
</Header> </Header>
<Body> <Body>

View File

@ -8,7 +8,7 @@ import { Header, Body } from '../detail';
import FileDetails from './file-details'; import FileDetails from './file-details';
import { MetadataContext } from '../../../metadata'; import { MetadataContext } from '../../../metadata';
import { MetadataDetailsProvider } from '../../../metadata/hooks'; import { MetadataDetailsProvider } from '../../../metadata/hooks';
import Settings from '../../../metadata/components/metadata-details/settings'; import { AI, Settings } from '../../../metadata/components/metadata-details';
import './index.css'; import './index.css';
@ -54,6 +54,7 @@ const EmbeddedFileDetails = ({ repoID, repoInfo, dirent, path, onClose, width =
style={{ width }} style={{ width }}
> >
<Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={onClose} component={headerComponent} > <Header title={dirent?.name || ''} icon={Utils.getDirentIcon(dirent, true)} onClose={onClose} component={headerComponent} >
<AI />
<Settings /> <Settings />
</Header> </Header>
<Body> <Body>

View File

@ -0,0 +1,109 @@
import React, { useContext, useCallback, useMemo } from 'react';
import metadataAPI from '../metadata/api';
import { Utils } from '../utils/utils';
import toaster from '../components/toast';
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants';
import { gettext, lang } from '../utils/constants';
// This hook provides content related to metadata ai operation
const MetadataAIOperationsContext = React.createContext(null);
export const MetadataAIOperationsProvider = ({
repoID,
enableMetadata = false,
enableOCR = false,
enableTags = false,
tagsLang,
repoInfo,
children
}) => {
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
const canModify = useMemo(() => permission === 'rw', [permission]);
const OCRSuccessCallBack = useCallback(({ parentDir, fileName, ocrResult } = {}) => {
toaster.success(gettext('Successfully OCR'));
if (!ocrResult) return;
const update = { [PRIVATE_COLUMN_KEY.OCR]: ocrResult };
metadataAPI.modifyRecord(repoID, { parentDir, fileName }, update).then(res => {
const eventBus = window?.sfMetadataContext?.eventBus;
eventBus && eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { parentDir, fileName }, update);
});
}, [repoID]);
const onOCR = useCallback(({ parentDir, fileName }, { success_callback, fail_callback } = {}) => {
const filePath = Utils.joinPath(parentDir, fileName);
metadataAPI.ocr(repoID, filePath).then(res => {
const ocrResult = res.data.ocr_result;
const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null;
success_callback && success_callback({ parentDir, fileName, ocrResult: validResult });
}).catch(error => {
const errorMessage = gettext('OCR failed');
toaster.danger(errorMessage);
fail_callback && fail_callback();
});
}, [repoID]);
const generateDescription = useCallback(({ parentDir, fileName }, { success_callback, fail_callback } = {}) => {
const filePath = Utils.joinPath(parentDir, fileName);
const isImage = Utils.imageCheck(fileName);
let APIName = '';
if (Utils.isDescriptionSupportedFile(fileName)) {
APIName = isImage ? 'imageCaption' : 'generateDescription';
}
if (!APIName) return;
metadataAPI[APIName](repoID, filePath, lang).then(res => {
const description = res?.data?.summary || res.data.desc || '';
success_callback && success_callback({ parentDir, fileName, description });
}).catch(error => {
const errorMessage = isImage ? gettext('Failed to generate image description') : gettext('Failed to generate description');
toaster.danger(errorMessage);
fail_callback && fail_callback();
});
}, [repoID]);
const extractFilesDetails = useCallback((objIds, { success_callback, fail_callback } = {}) => {
metadataAPI.extractFileDetails(repoID, objIds).then(res => {
const details = res?.data?.details || [];
success_callback && success_callback({ details });
}).catch(error => {
const errorMessage = gettext('Failed to extract file details');
toaster.danger(errorMessage);
fail_callback && fail_callback();
});
}, [repoID]);
const extractFileDetails = useCallback((objId, { success_callback, fail_callback } = {}) => {
extractFilesDetails([objId], {
success_callback: ({ details }) => {
success_callback && success_callback({ detail: details[0] });
},
fail_callback
});
}, [extractFilesDetails]);
return (
<MetadataAIOperationsContext.Provider value={{
enableMetadata,
enableOCR,
enableTags,
tagsLang,
canModify,
onOCR,
OCRSuccessCallBack,
generateDescription,
extractFilesDetails,
extractFileDetails,
}}>
{children}
</MetadataAIOperationsContext.Provider>
);
};
export const useMetadataAIOperations = () => {
const context = useContext(MetadataAIOperationsContext);
if (!context) {
throw new Error('\'MetadataAIOperationsContext\' is null');
}
return context;
};

View File

@ -1,54 +0,0 @@
import React, { useContext, useCallback, useMemo } from 'react';
import metadataAPI from '../metadata/api';
import { Utils } from '../utils/utils';
import toaster from '../components/toast';
import { PRIVATE_COLUMN_KEY, EVENT_BUS_TYPE } from '../metadata/constants';
import { gettext } from '../utils/constants';
// This hook provides content related to metadata record operation
const MetadataOperationsContext = React.createContext(null);
export const MetadataOperationsProvider = ({ repoID, enableMetadata, enableOCR, repoInfo, children }) => {
const permission = useMemo(() => repoInfo.permission !== 'admin' && repoInfo.permission !== 'rw' ? 'r' : 'rw', [repoInfo]);
const canModify = useMemo(() => permission === 'rw', [permission]);
const onOCR = useCallback((parentDir, fileName) => {
const filePath = Utils.joinPath(parentDir, fileName);
metadataAPI.ocr(repoID, filePath).then(res => {
const ocrResult = res.data.ocr_result;
const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null;
toaster.success(gettext('Successfully OCR'));
if (validResult) {
const update = { [PRIVATE_COLUMN_KEY.OCR]: validResult };
metadataAPI.modifyRecord(repoID, { parentDir, fileName }, update).then(res => {
const eventBus = window?.sfMetadataContext?.eventBus;
if (eventBus) {
eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_CHANGED, { parentDir, fileName }, update);
}
});
}
}).catch(error => {
const errorMessage = Utils.getErrorMsg(error);
toaster.danger(errorMessage);
});
}, [repoID]);
let value = {};
if (canModify && enableMetadata && enableOCR) {
value['onOCR'] = onOCR;
}
return (
<MetadataOperationsContext.Provider value={value}>
{children}
</MetadataOperationsContext.Provider>
);
};
export const useMetadataOperations = () => {
const context = useContext(MetadataOperationsContext);
if (!context) {
throw new Error('\'MetadataOperationsContext\' is null');
}
return context;
};

View File

@ -2,7 +2,7 @@ import React, { useContext, useEffect, useCallback, useState, useMemo } from 're
import metadataAPI from '../metadata/api'; import metadataAPI from '../metadata/api';
import { Utils } from '../utils/utils'; import { Utils } from '../utils/utils';
import toaster from '../components/toast'; import toaster from '../components/toast';
import { MetadataOperationsProvider } from './metadata-operation'; import { MetadataAIOperationsProvider } from './metadata-ai-operation';
// This hook provides content related to seahub interaction, such as whether to enable extended attributes // This hook provides content related to seahub interaction, such as whether to enable extended attributes
const MetadataStatusContext = React.createContext(null); const MetadataStatusContext = React.createContext(null);
@ -119,14 +119,16 @@ export const MetadataStatusProvider = ({ repoID, currentRepoInfo, hideMetadataVi
}} }}
> >
{!isLoading && ( {!isLoading && (
<MetadataOperationsProvider <MetadataAIOperationsProvider
repoID={repoID} repoID={repoID}
enableMetadata={enableMetadata} enableMetadata={enableMetadata}
enableOCR={enableOCR} enableOCR={enableOCR}
enableTags={enableTags}
tagsLang={tagsLang}
repoInfo={currentRepoInfo} repoInfo={currentRepoInfo}
> >
{children} {children}
</MetadataOperationsProvider> </MetadataAIOperationsProvider>
)} )}
</MetadataStatusContext.Provider> </MetadataStatusContext.Provider>
); );

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { LongTextFormatter } from '@seafile/sf-metadata-ui-component'; import { LongTextFormatter } from '@seafile/sf-metadata-ui-component';
import Editor from '../../cell-editors/long-text-editor'; import Editor from '../../cell-editors/long-text-editor';
@ -10,6 +10,8 @@ const LongTextEditor = ({ field, value: oldValue, onChange }) => {
const [value, setValue] = useState(oldValue); const [value, setValue] = useState(oldValue);
const [showEditor, setShowEditor] = useState(false); const [showEditor, setShowEditor] = useState(false);
const valueRef = useRef(null);
const openEditor = useCallback(() => { const openEditor = useCallback(() => {
setShowEditor(true); setShowEditor(true);
}, []); }, []);
@ -23,6 +25,13 @@ const LongTextEditor = ({ field, value: oldValue, onChange }) => {
setShowEditor(false); setShowEditor(false);
}, []); }, []);
useEffect(() => {
if (showEditor) return;
if (valueRef.current === oldValue) return;
setValue(oldValue);
valueRef.current = oldValue;
}, [showEditor, oldValue]);
const isEmpty = !value || !value.trim(); const isEmpty = !value || !value.trim();
return ( return (

View File

@ -0,0 +1,3 @@
.sf-metadata-ai-dropdown-menu .dropdown-menu {
left: -8px !important;
}

View File

@ -0,0 +1,180 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';
import { ModalPortal } from '@seafile/sf-metadata-ui-component';
import Icon from '../../../../components/icon';
import { useMetadataDetails } from '../../../hooks';
import { useMetadataStatus } from '../../../../hooks';
import { gettext } from '../../../../utils/constants';
import { Utils } from '../../../../utils/utils';
import { getFileNameFromRecord, getFileObjIdFromRecord, getParentDirFromRecord, getRecordIdFromRecord } from '../../../utils/cell';
import { getColumnByKey } from '../../../utils/column';
import { PRIVATE_COLUMN_KEY } from '../constants';
import { useMetadataAIOperations } from '../../../../hooks/metadata-ai-operation';
import FileTagsDialog from '../../dialog/file-tags-dialog';
import { checkIsDir } from '../../../utils/row';
import './index.css';
const OPERATION = {
GENERATE_DESCRIPTION: 'generate-description',
OCR: 'ocr',
FILE_TAGS: 'file-tags',
FILE_DETAIL: 'file-detail',
};
const AI = () => {
const [isMenuShow, setMenuShow] = useState(false);
const [isFileTagsDialogShow, setFileTagsDialogShow] = useState(false);
const { enableMetadata, enableTags, enableOCR } = useMetadataStatus();
const { canModifyRecord, columns, record, onChange, onLocalRecordChange, updateFileTags } = useMetadataDetails();
const { onOCR, generateDescription, extractFileDetails } = useMetadataAIOperations();
const options = useMemo(() => {
if (!canModifyRecord) return [];
if (!record) return [];
if (checkIsDir(record)) return [];
const descriptionColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.FILE_DESCRIPTION);
const fileName = getFileNameFromRecord(record);
const isImage = Utils.imageCheck(fileName);
const isVideo = Utils.videoCheck(fileName);
const isDescribableDoc = Utils.isDescriptionSupportedFile(fileName);
let list = [];
if (descriptionColumn && isDescribableDoc) {
list.push({
value: OPERATION.GENERATE_DESCRIPTION,
label: isImage ? gettext('Generate image description') : gettext('Generate description'),
record
});
}
if (enableOCR && isImage) {
list.push({ value: OPERATION.OCR, label: gettext('OCR'), record });
}
if (isImage || isVideo) {
list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record });
}
if (enableTags && isDescribableDoc && !isVideo) {
list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record });
}
return list;
}, [enableOCR, enableTags, canModifyRecord, columns, record]);
const onToggle = useCallback((event) => {
event && event.preventDefault();
event && event.stopPropagation();
setMenuShow(!isMenuShow);
}, [isMenuShow]);
const toggleFileTagsDialog = useCallback(() => {
setFileTagsDialogShow(!isFileTagsDialogShow);
}, [isFileTagsDialogShow]);
const handelOperation = useCallback((op) => {
const { value: opType, record } = op;
const recordId = getRecordIdFromRecord(record);
const parentDir = getParentDirFromRecord(record);
const fileName = getFileNameFromRecord(record);
const objId = getFileObjIdFromRecord(record);
switch (opType) {
case OPERATION.GENERATE_DESCRIPTION: {
generateDescription({ parentDir, fileName }, {
success_callback: ({ description }) => {
if (!description) return;
onChange && onChange(PRIVATE_COLUMN_KEY.FILE_DESCRIPTION, description);
},
});
break;
}
case OPERATION.OCR: {
onOCR({ parentDir, fileName }, {
success_callback: ({ ocrResult }) => {
if (!ocrResult) return;
onChange && onChange(PRIVATE_COLUMN_KEY.OCR, JSON.stringify(ocrResult));
},
});
break;
}
case OPERATION.FILE_TAGS: {
setFileTagsDialogShow(true);
break;
}
case OPERATION.FILE_DETAIL: {
extractFileDetails(objId, {
success_callback: ({ detail }) => {
if (!detail) return;
const captureColumn = getColumnByKey(columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME);
if (captureColumn) {
const value = detail[PRIVATE_COLUMN_KEY.CAPTURE_TIME];
value && onChange && onChange(PRIVATE_COLUMN_KEY.CAPTURE_TIME, value);
}
const fileDetails = detail[PRIVATE_COLUMN_KEY.FILE_DETAILS];
const location = detail[PRIVATE_COLUMN_KEY.LOCATION];
let update = {};
if (fileDetails) {
update[PRIVATE_COLUMN_KEY.FILE_DETAILS] = fileDetails;
}
if (location) {
update[PRIVATE_COLUMN_KEY.LOCATION] = location;
}
Object.keys(update).length > 0 && onLocalRecordChange(recordId, update);
},
});
break;
}
default: {
setMenuShow(false);
break;
}
}
}, [columns, generateDescription, onOCR, extractFileDetails, onChange, onLocalRecordChange]);
const renderDropdown = useCallback(() => {
if (!enableMetadata) return null;
if (!canModifyRecord) return null;
if (!record) return null;
if (options.length === 0) return null;
return (
<Dropdown className="sf-metadata-dropdown-menu" isOpen={isMenuShow} toggle={onToggle}>
<DropdownToggle
tag="span"
role="button"
data-toggle="dropdown"
aria-expanded={isMenuShow}
title={gettext('AI')}
aria-label={gettext('AI')}
tabIndex={0}
>
<div className="detail-control mr-2">
<Icon symbol="ai" className="detail-control-close" />
</div>
</DropdownToggle>
{isMenuShow && (
<ModalPortal>
<div className="sf-metadata-ai-dropdown-menu large">
<DropdownMenu right={true}>
{options.map(op => (<DropdownItem key={op.value} onClick={() => handelOperation(op)}>{op.label}</DropdownItem>))}
</DropdownMenu>
</div>
</ModalPortal>
)}
</Dropdown>
);
}, [isMenuShow, enableMetadata, canModifyRecord, record, options, onToggle, handelOperation]);
return (
<>
{renderDropdown()}
{isFileTagsDialogShow && (
<FileTagsDialog record={record} onToggle={toggleFileTagsDialog} onSubmit={updateFileTags} />
)}
</>
);
};
export default AI;

View File

@ -9,6 +9,8 @@ import { PRIVATE_COLUMN_KEY } from '../../constants';
import Location from './location'; import Location from './location';
import { useMetadataDetails } from '../../hooks'; import { useMetadataDetails } from '../../hooks';
import { checkIsDir } from '../../utils/row'; import { checkIsDir } from '../../utils/row';
import AI from './ai';
import Settings from './settings';
import './index.css'; import './index.css';
@ -64,3 +66,7 @@ const MetadataDetails = () => {
}; };
export default MetadataDetails; export default MetadataDetails;
export {
AI,
Settings,
};

View File

@ -10,7 +10,7 @@ const Settings = () => {
const [isShowSetter, setShowSetter] = useState(false); const [isShowSetter, setShowSetter] = useState(false);
const { enableMetadata } = useMetadataStatus(); const { enableMetadata } = useMetadataStatus();
const { modifyColumnOrder, modifyHiddenColumns, columns, canModifyDetails } = useMetadataDetails(); const { modifyColumnOrder, modifyHiddenColumns, record, columns, canModifyDetails } = useMetadataDetails();
const hiddenColumns = useMemo(() => columns.filter(c => !c.shown).map(c => c.key), [columns]); const hiddenColumns = useMemo(() => columns.filter(c => !c.shown).map(c => c.key), [columns]);
const onSetterToggle = useCallback(() => { const onSetterToggle = useCallback(() => {
@ -20,6 +20,7 @@ const Settings = () => {
if (!enableMetadata) return null; if (!enableMetadata) return null;
if (!canModifyDetails) return null; if (!canModifyDetails) return null;
if (!record) return null;
return ( return (
<> <>

View File

@ -49,7 +49,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent
return [...exitColumnsOrder, ...newColumns]; return [...exitColumnsOrder, ...newColumns];
}, [originColumns, detailsSettings]); }, [originColumns, detailsSettings]);
const localRecordChanged = useCallback((recordId, updates) => { const onLocalRecordChange = useCallback((recordId, updates) => {
if (getRecordIdFromRecord(record) !== recordId) return; if (getRecordIdFromRecord(record) !== recordId) return;
const newRecord = { ...record, ...updates }; const newRecord = { ...record, ...updates };
setRecord(newRecord); setRecord(newRecord);
@ -180,11 +180,11 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent
useEffect(() => { useEffect(() => {
const eventBus = window?.sfMetadataContext?.eventBus; const eventBus = window?.sfMetadataContext?.eventBus;
if (!eventBus) return; if (!eventBus) return;
const unsubscribeLocalRecordChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, localRecordChanged); const unsubscribeLocalRecordChanged = eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, onLocalRecordChange);
return () => { return () => {
unsubscribeLocalRecordChanged(); unsubscribeLocalRecordChanged();
}; };
}, [localRecordChanged]); }, [onLocalRecordChange]);
return ( return (
<MetadataDetailsContext.Provider <MetadataDetailsContext.Provider
@ -195,6 +195,7 @@ export const MetadataDetailsProvider = ({ repoID, repoInfo, path, dirent, dirent
record, record,
columns, columns,
onChange, onChange,
onLocalRecordChange,
modifyColumnData, modifyColumnData,
updateFileTags, updateFileTags,
modifyHiddenColumns, modifyHiddenColumns,

View File

@ -16,6 +16,7 @@ import { openInNewTab, openParentFolder } from '../../../utils/file';
import DeleteFolderDialog from '../../../../components/dialog/delete-folder-dialog'; import DeleteFolderDialog from '../../../../components/dialog/delete-folder-dialog';
import MoveDirent from '../../../../components/dialog/move-dirent-dialog'; import MoveDirent from '../../../../components/dialog/move-dirent-dialog';
import { Dirent } from '../../../../models'; import { Dirent } from '../../../../models';
import { useMetadataAIOperations } from '../../../../hooks/metadata-ai-operation';
const OPERATION = { const OPERATION = {
CLEAR_SELECTED: 'clear-selected', CLEAR_SELECTED: 'clear-selected',
@ -24,7 +25,7 @@ const OPERATION = {
OPEN_IN_NEW_TAB: 'open-new-tab', OPEN_IN_NEW_TAB: 'open-new-tab',
GENERATE_DESCRIPTION: 'generate-description', GENERATE_DESCRIPTION: 'generate-description',
OCR: 'ocr', OCR: 'ocr',
IMAGE_CAPTION: 'image-caption', IMAGE_DESCRIPTION: 'image-description',
FILE_TAGS: 'file-tags', FILE_TAGS: 'file-tags',
DELETE_RECORD: 'delete-record', DELETE_RECORD: 'delete-record',
DELETE_RECORDS: 'delete-records', DELETE_RECORDS: 'delete-records',
@ -49,6 +50,7 @@ const ContextMenu = ({
const { metadata } = useMetadataView(); const { metadata } = useMetadataView();
const { enableOCR } = useMetadataStatus(); const { enableOCR } = useMetadataStatus();
const { onOCR, generateDescription, extractFilesDetails } = useMetadataAIOperations();
const repoID = window.sfMetadataStore.repoId; const repoID = window.sfMetadataStore.repoId;
@ -56,7 +58,7 @@ const ContextMenu = ({
return window.sfMetadataContext.canModifyRow(row); return window.sfMetadataContext.canModifyRow(row);
}; };
const checkIsDescribableDoc = useCallback((record) => { const checkIsDescribableFile = useCallback((record) => {
const fileName = getFileNameFromRecord(record); const fileName = getFileNameFromRecord(record);
return checkCanModifyRow(record) && Utils.isDescriptionSupportedFile(fileName); return checkCanModifyRow(record) && Utils.isDescriptionSupportedFile(fileName);
}, []); }, []);
@ -142,6 +144,10 @@ const ContextMenu = ({
list.push({ value: OPERATION.DELETE_RECORDS, label: gettext('Delete'), records: ableDeleteRecords }); list.push({ value: OPERATION.DELETE_RECORDS, label: gettext('Delete'), records: ableDeleteRecords });
} }
const imageOrVideoRecords = records.filter(record => { const imageOrVideoRecords = records.filter(record => {
const isFolder = checkIsDir(record);
if (isFolder) return false;
const canModifyRow = checkCanModifyRow(record);
if (!canModifyRow) return false;
const fileName = getFileNameFromRecord(record); const fileName = getFileNameFromRecord(record);
return Utils.imageCheck(fileName) || Utils.videoCheck(fileName); return Utils.imageCheck(fileName) || Utils.videoCheck(fileName);
}); });
@ -165,24 +171,29 @@ const ContextMenu = ({
list.push({ value: OPERATION.OPEN_PARENT_FOLDER, label: gettext('Open parent folder'), record }); list.push({ value: OPERATION.OPEN_PARENT_FOLDER, label: gettext('Open parent folder'), record });
const fileName = getFileNameFromRecord(record); const fileName = getFileNameFromRecord(record);
if (descriptionColumn) { if (!isFolder && canModifyRow) {
if (checkIsDescribableDoc(record)) { const isDescribableFile = checkIsDescribableFile(record);
list.push({ value: OPERATION.GENERATE_DESCRIPTION, label: gettext('Generate description'), record }); const isImage = Utils.imageCheck(fileName);
} else if (canModifyRow && Utils.imageCheck(fileName)) { const isVideo = Utils.videoCheck(fileName);
list.push({ value: OPERATION.IMAGE_CAPTION, label: gettext('Generate image description'), record }); if (descriptionColumn && isDescribableFile) {
list.push({
value: OPERATION.GENERATE_DESCRIPTION,
label: Utils.imageCheck(fileName) ? gettext('Generate image description') : gettext('Generate description'),
record
});
} }
}
if (enableOCR && canModifyRow && Utils.imageCheck(fileName)) { if (enableOCR && isImage) {
list.push({ value: OPERATION.OCR, label: gettext('OCR'), record }); list.push({ value: OPERATION.OCR, label: gettext('OCR'), record });
} }
if (canModifyRow && (Utils.imageCheck(fileName) || Utils.videoCheck(fileName))) { if (isImage || isVideo) {
list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record: record }); list.push({ value: OPERATION.FILE_DETAIL, label: gettext('Extract file detail'), record: record });
} }
if (tagsColumn && canModifyRow && (Utils.imageCheck(fileName) || checkIsDescribableDoc(record))) { if (tagsColumn && isDescribableFile && !isVideo) {
list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record: record }); list.push({ value: OPERATION.FILE_TAGS, label: gettext('Generate file tags'), record: record });
}
} }
// handle delete folder/file // handle delete folder/file
@ -199,7 +210,7 @@ const ContextMenu = ({
} }
return list; return list;
}, [visible, isGroupView, selectedPosition, recordMetrics, selectedRange, metadata, recordGetterByIndex, checkIsDescribableDoc, enableOCR, getAbleDeleteRecords]); }, [visible, isGroupView, selectedPosition, recordMetrics, selectedRange, metadata, recordGetterByIndex, checkIsDescribableFile, enableOCR, getAbleDeleteRecords]);
const handleHide = useCallback((event) => { const handleHide = useCallback((event) => {
if (!menuRef.current && visible) { if (!menuRef.current && visible) {
@ -212,83 +223,46 @@ const ContextMenu = ({
} }
}, [menuRef, visible]); }, [menuRef, visible]);
const generateDescription = useCallback((record) => { const handelGenerateDescription = useCallback((record) => {
const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; if (!checkCanModifyRow(record)) return;
let path = ''; const parentDir = getParentDirFromRecord(record);
let idOldRecordData = {};
let idOriginalOldRecordData = {};
const fileName = getFileNameFromRecord(record); const fileName = getFileNameFromRecord(record);
if (Utils.isDescriptionSupportedFile(fileName) && checkCanModifyRow(record)) { if (!fileName || !parentDir) return;
const parentDir = getParentDirFromRecord(record); const checkIsDescribableFile = Utils.isDescriptionSupportedFile(fileName);
path = Utils.joinPath(parentDir, fileName); if (!checkIsDescribableFile) return;
idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [descriptionColumnKey]: record[descriptionColumnKey] };
idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [descriptionColumnKey]: record[descriptionColumnKey] };
}
if (path === '') return;
window.sfMetadataContext.generateDescription(path).then(res => {
const description = res.data.summary;
const updateRecordId = record[PRIVATE_COLUMN_KEY.ID];
const recordIds = [updateRecordId];
let idRecordUpdates = {};
let idOriginalRecordUpdates = {};
idRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description };
idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description };
updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData });
}).catch(error => {
const errorMessage = gettext('Failed to generate description');
toaster.danger(errorMessage);
});
}, [updateRecords]);
const imageCaption = useCallback((record) => { const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION;
const summaryColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; let idOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [descriptionColumnKey]: record[descriptionColumnKey] } };
let path = ''; let idOriginalOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [descriptionColumnKey]: record[descriptionColumnKey] } };
let idOldRecordData = {}; generateDescription({ parentDir, fileName }, {
let idOriginalOldRecordData = {}; success_callback: ({ description }) => {
const fileName = getFileNameFromRecord(record); const updateRecordId = record[PRIVATE_COLUMN_KEY.ID];
if (Utils.imageCheck(fileName) && checkCanModifyRow(record)) { const recordIds = [updateRecordId];
const parentDir = getParentDirFromRecord(record); let idRecordUpdates = {};
path = Utils.joinPath(parentDir, fileName); let idOriginalRecordUpdates = {};
idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [summaryColumnKey]: record[summaryColumnKey] }; idRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description };
idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [summaryColumnKey]: record[summaryColumnKey] }; idOriginalRecordUpdates[updateRecordId] = { [descriptionColumnKey]: description };
} updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData });
if (path === '') return; }
window.sfMetadataContext.imageCaption(path).then(res => {
const desc = res.data.desc;
const updateRecordId = record[PRIVATE_COLUMN_KEY.ID];
const recordIds = [updateRecordId];
let idRecordUpdates = {};
let idOriginalRecordUpdates = {};
idRecordUpdates[updateRecordId] = { [summaryColumnKey]: desc };
idOriginalRecordUpdates[updateRecordId] = { [summaryColumnKey]: desc };
updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData });
}).catch(error => {
const errorMessage = gettext('Failed to generate image description');
toaster.danger(errorMessage);
}); });
}, [updateRecords]); }, [updateRecords, generateDescription]);
const toggleFileTagsRecord = useCallback((record = null) => { const toggleFileTagsRecord = useCallback((record = null) => {
setFileTagsRecord(record); setFileTagsRecord(record);
}, []); }, []);
const ocr = useCallback((record) => { const ocr = useCallback((record) => {
const ocrResultColumnKey = PRIVATE_COLUMN_KEY.OCR; if (!checkCanModifyRow(record)) return;
let path = ''; const parentDir = getParentDirFromRecord(record);
let idOldRecordData = {};
let idOriginalOldRecordData = {};
const fileName = getFileNameFromRecord(record); const fileName = getFileNameFromRecord(record);
if (Utils.imageCheck(fileName) && checkCanModifyRow(record)) { if (!Utils.imageCheck(fileName)) return;
const parentDir = getParentDirFromRecord(record);
path = Utils.joinPath(parentDir, fileName); const ocrResultColumnKey = PRIVATE_COLUMN_KEY.OCR;
idOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [ocrResultColumnKey]: record[ocrResultColumnKey] }; let idOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [ocrResultColumnKey]: record[ocrResultColumnKey] } };
idOriginalOldRecordData[record[PRIVATE_COLUMN_KEY.ID]] = { [ocrResultColumnKey]: record[ocrResultColumnKey] }; let idOriginalOldRecordData = { [record[PRIVATE_COLUMN_KEY.ID]]: { [ocrResultColumnKey]: record[ocrResultColumnKey] } };
} onOCR({ parentDir, fileName }, {
if (path === '') return; success_callback: ({ ocrResult }) => {
window.sfMetadataContext.ocr(path).then(res => { if (!ocrResult) return;
const ocrResult = res.data.ocr_result;
const validResult = Array.isArray(ocrResult) && ocrResult.length > 0 ? JSON.stringify(ocrResult) : null;
if (validResult) {
const updateRecordId = record[PRIVATE_COLUMN_KEY.ID]; const updateRecordId = record[PRIVATE_COLUMN_KEY.ID];
const recordIds = [updateRecordId]; const recordIds = [updateRecordId];
let idRecordUpdates = {}; let idRecordUpdates = {};
@ -296,12 +270,9 @@ const ContextMenu = ({
idRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; idRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null };
idOriginalRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null }; idOriginalRecordUpdates[updateRecordId] = { [ocrResultColumnKey]: ocrResult ? JSON.stringify(ocrResult) : null };
updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData });
} },
}).catch(error => {
const errorMessage = gettext('OCR failed');
toaster.danger(errorMessage);
}); });
}, [updateRecords]); }, [updateRecords, onOCR]);
const updateFileDetails = useCallback((records) => { const updateFileDetails = useCallback((records) => {
const recordObjIds = records.map(record => getFileObjIdFromRecord(record)); const recordObjIds = records.map(record => getFileObjIdFromRecord(record));
@ -311,10 +282,10 @@ const ContextMenu = ({
} }
const recordIds = records.map(record => getRecordIdFromRecord(record)); const recordIds = records.map(record => getRecordIdFromRecord(record));
window.sfMetadataContext.extractFileDetails(recordObjIds).then(res => { extractFilesDetails(recordObjIds, {
const captureColumn = getColumnByKey(metadata.columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME); success_callback: ({ details }) => {
const captureColumn = getColumnByKey(metadata.columns, PRIVATE_COLUMN_KEY.CAPTURE_TIME);
if (captureColumn) { if (!captureColumn) return;
let idOldRecordData = {}; let idOldRecordData = {};
let idOriginalOldRecordData = {}; let idOriginalOldRecordData = {};
const captureColumnKey = PRIVATE_COLUMN_KEY.CAPTURE_TIME; const captureColumnKey = PRIVATE_COLUMN_KEY.CAPTURE_TIME;
@ -324,18 +295,15 @@ const ContextMenu = ({
}); });
let idRecordUpdates = {}; let idRecordUpdates = {};
let idOriginalRecordUpdates = {}; let idOriginalRecordUpdates = {};
res.data.details.forEach(detail => { details.forEach(detail => {
const updateRecordId = detail[PRIVATE_COLUMN_KEY.ID]; const updateRecordId = detail[PRIVATE_COLUMN_KEY.ID];
idRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] }; idRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] };
idOriginalRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] }; idOriginalRecordUpdates[updateRecordId] = { [captureColumnKey]: detail[captureColumnKey] };
}); });
updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData }); updateRecords({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData });
} }
}).catch(error => {
const errorMessage = gettext('Failed to extract file details');
toaster.danger(errorMessage);
}); });
}, [metadata, updateRecords]); }, [metadata, extractFilesDetails, updateRecords]);
const handleOptionClick = useCallback((event, option) => { const handleOptionClick = useCallback((event, option) => {
event.stopPropagation(); event.stopPropagation();
@ -363,13 +331,7 @@ const ContextMenu = ({
case OPERATION.GENERATE_DESCRIPTION: { case OPERATION.GENERATE_DESCRIPTION: {
const { record } = option; const { record } = option;
if (!record) break; if (!record) break;
generateDescription(record); handelGenerateDescription(record);
break;
}
case OPERATION.IMAGE_CAPTION: {
const { record } = option;
if (!record) break;
imageCaption(record);
break; break;
} }
case OPERATION.FILE_TAGS: { case OPERATION.FILE_TAGS: {
@ -433,7 +395,7 @@ const ContextMenu = ({
} }
} }
setVisible(false); setVisible(false);
}, [repoID, onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord, toggleMoveDialog]); }, [repoID, onCopySelected, onClearSelected, handelGenerateDescription, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord, toggleMoveDialog]);
const getMenuPosition = useCallback((x = 0, y = 0) => { const getMenuPosition = useCallback((x = 0, y = 0) => {
let menuStyles = { let menuStyles = {

View File

@ -15,13 +15,15 @@
background-color: inherit; background-color: inherit;
} }
.sdoc-content-right-panel .detail-header .seafile-multicolor-icon-set-up { .sdoc-content-right-panel .detail-header .seafile-multicolor-icon-set-up,
.sdoc-content-right-panel .detail-header .seafile-multicolor-icon-ai {
fill: #999; fill: #999;
font-weight: 700; font-weight: 700;
font-size: 16px; font-size: 16px;
} }
.sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-set-up { .sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-set-up,
.sdoc-content-right-panel .detail-header .detail-control:hover .seafile-multicolor-icon-ai {
fill: #5a5a5a; fill: #5a5a5a;
} }

View File

@ -20,7 +20,7 @@ import CopyMoveDirentProgressDialog from './components/dialog/copy-move-dirent-p
import RepoInfoBar from './components/repo-info-bar'; import RepoInfoBar from './components/repo-info-bar';
import RepoTag from './models/repo-tag'; import RepoTag from './models/repo-tag';
import { GRID_MODE, LIST_MODE } from './components/dir-view-mode/constants'; import { GRID_MODE, LIST_MODE } from './components/dir-view-mode/constants';
import { MetadataOperationsProvider } from './hooks/metadata-operation'; import { MetadataAIOperationsProvider } from './hooks/metadata-ai-operation';
import './css/shared-dir-view.css'; import './css/shared-dir-view.css';
import './css/grid-view.css'; import './css/grid-view.css';
@ -441,7 +441,7 @@ class SharedDirView extends React.Component {
const isDesktop = Utils.isDesktop(); const isDesktop = Utils.isDesktop();
const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn'; const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn';
return ( return (
<MetadataOperationsProvider repoID={repoID} enableMetadata={false} enableOCR={false} repoInfo={{ permission: 'r' }} > <MetadataAIOperationsProvider repoID={repoID} enableMetadata={false} enableOCR={false} repoInfo={{ permission: 'r' }} >
<div className="h-100 d-flex flex-column"> <div className="h-100 d-flex flex-column">
<div className="top-header d-flex justify-content-between"> <div className="top-header d-flex justify-content-between">
<a href={siteRoot}> <a href={siteRoot}>
@ -581,7 +581,7 @@ class SharedDirView extends React.Component {
/> />
</ModalPortal> </ModalPortal>
} }
</MetadataOperationsProvider> </MetadataAIOperationsProvider>
); );
} }
} }

View File

@ -1055,7 +1055,12 @@ export const Utils = {
}, },
isDescriptionSupportedFile: function (filePath) { isDescriptionSupportedFile: function (filePath) {
return Utils.isSdocFile(filePath) || Utils.isMarkdownFile(filePath) || Utils.pdfCheck(filePath) || Utils.isDocxFile(filePath) || Utils.isPptxFile(filePath); return Utils.isSdocFile(filePath) ||
Utils.isMarkdownFile(filePath) ||
Utils.pdfCheck(filePath) ||
Utils.isDocxFile(filePath) ||
Utils.isPptxFile(filePath) ||
Utils.imageCheck(filePath);
}, },
isFileMetadata: function (type) { isFileMetadata: function (type) {