diff --git a/frontend/src/assets/icons/arrow.svg b/frontend/src/assets/icons/arrow.svg new file mode 100644 index 00000000000..6befa780a16 --- /dev/null +++ b/frontend/src/assets/icons/arrow.svg @@ -0,0 +1,17 @@ + + + + +arrow +Created with Sketch. + + + + + + diff --git a/frontend/src/assets/icons/helpful-selected.svg b/frontend/src/assets/icons/helpful-selected.svg new file mode 100644 index 00000000000..f95262298bb --- /dev/null +++ b/frontend/src/assets/icons/helpful-selected.svg @@ -0,0 +1,15 @@ + + + + +helpful-selected + + + + diff --git a/frontend/src/assets/icons/helpful.svg b/frontend/src/assets/icons/helpful.svg new file mode 100644 index 00000000000..82f2a1bb460 --- /dev/null +++ b/frontend/src/assets/icons/helpful.svg @@ -0,0 +1,18 @@ + + + + +helpful + + + + diff --git a/frontend/src/assets/icons/helpless-selected.svg b/frontend/src/assets/icons/helpless-selected.svg new file mode 100644 index 00000000000..2faa3aa5a2f --- /dev/null +++ b/frontend/src/assets/icons/helpless-selected.svg @@ -0,0 +1,15 @@ + + + + +helpless-selected + + + + diff --git a/frontend/src/assets/icons/helpless.svg b/frontend/src/assets/icons/helpless.svg new file mode 100644 index 00000000000..20a4b8fdb0c --- /dev/null +++ b/frontend/src/assets/icons/helpless.svg @@ -0,0 +1,18 @@ + + + + +helpless + + + + diff --git a/frontend/src/assets/icons/send.svg b/frontend/src/assets/icons/send.svg new file mode 100644 index 00000000000..b34f6df1667 --- /dev/null +++ b/frontend/src/assets/icons/send.svg @@ -0,0 +1,14 @@ + + + + +send + + + + diff --git a/frontend/src/components/search/ai-search-ask.css b/frontend/src/components/search/ai-search-ask.css new file mode 100644 index 00000000000..b9014eca61f --- /dev/null +++ b/frontend/src/components/search/ai-search-ask.css @@ -0,0 +1,79 @@ +.search-container.show.ai-search-ask { + width: 800px; +} + +.ai-search-ask .ai-search-ask-header { + display: flex; + align-items: center; + padding: 1rem; + border-bottom: 1px solid rgba(0, 40, 100, 0.12); +} + +.ai-search-ask .ai-search-ask-header .ai-search-ask-return { + padding: 0 4px; + transform: rotate(180deg); + line-height: 10px; + cursor: pointer; +} + +.ai-search-ask .ai-search-ask-header .ai-search-ask-return .seafile-multicolor-icon-arrow { + opacity: 0.6; +} + +.ai-search-ask .ai-search-ask-header .ai-search-ask-return:hover .seafile-multicolor-icon-arrow { + opacity: 0.8; +} + +.ai-search-ask .ai-search-ask-body { + display: flex; + max-height: 400px; + overflow-y: auto; +} + +.ai-search-ask .ai-search-ask-body .ai-search-ask-body-left { + flex-shrink: 0; + margin-right: 1rem; +} + +.ai-search-ask .ai-search-ask-body .ai-search-ask-body-right { + line-height: 1.8; + font-size: 14px; + width: 100%; +} + +.ai-search-ask .ai-search-ask-footer { + border-top: 1px solid rgba(0, 40, 100, 0.12); + margin: 0 1rem; + padding: 1rem 0; +} + +.ai-search-ask .ai-search-ask-footer .ai-search-ask-footer-btn { + width: 16px; + height: 16px; + position: absolute; + right: 8px; + top: 8px; + background-color: #fff; + cursor: pointer; +} + +.ai-search-ask .ai-search-ask-footer .ai-search-ask-footer-btn .seafile-multicolor-icon-send { + color: #ff8000; +} + +.ai-search-ask .ai-search-ask-footer .ai-search-ask-footer-btn:hover .seafile-multicolor-icon-send { + color: #d96d00; +} + +@media (max-width: 768px) { + + .search-container.show.ai-search-ask { + width: 100%; + } + + .ai-search-ask .search-input { + box-shadow: none; + width: 100% !important; + } + +} diff --git a/frontend/src/components/search/ai-search-ask.js b/frontend/src/components/search/ai-search-ask.js new file mode 100644 index 00000000000..d47a77c92c8 --- /dev/null +++ b/frontend/src/components/search/ai-search-ask.js @@ -0,0 +1,209 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import isHotkey from 'is-hotkey'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext } from '../../utils/constants'; +import toaster from '../toast'; +import Loading from '../loading'; +import Icon from '../icon'; +import { Utils } from '../../utils/utils'; +import { SEARCH_DELAY_TIME, getValueLength } from './constant'; +import AISearchRefrences from './ai-search-widgets/ai-search-refrences'; +import AISearchHelp from './ai-search-widgets/ai-search-help'; +import AISearchRobot from './ai-search-widgets/ai-search-robot'; + +import './ai-search-ask.css'; + +const INDEX_STATE = { + RUNNING: 'running', + UNCREATED: 'uncreated', + FINISHED: 'finished' +}; + +export default class AISearchAsk extends Component { + + static propTypes = { + value: PropTypes.string, + token: PropTypes.string, + repoID: PropTypes.string, + repoName: PropTypes.string, + indexState: PropTypes.string, + onItemClickHandler: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + value: props.value, + isLoading: false, + answeringResult: '', + hitFiles: [], + }; + this.timer = null; + this.isChineseInput = false; + } + + componentDidMount() { + document.addEventListener('compositionstart', this.onCompositionStart); + document.addEventListener('compositionend', this.onCompositionEnd); + this.onSearch(); + } + + componentWillUnmount() { + document.removeEventListener('compositionstart', this.onCompositionStart); + document.removeEventListener('compositionend', this.onCompositionEnd); + this.isChineseInput = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + onCompositionStart = () => { + this.isChineseInput = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + }; + + onCompositionEnd = () => { + this.isChineseInput = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.timer = setTimeout(() => { + this.onSearch(); + }, SEARCH_DELAY_TIME); + }; + + onChange = (event) => { + const newValue = event.target.value; + this.setState({ value: newValue }, () => { + if (!this.isChineseInput) { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.timer = setTimeout(() => { + this.onSearch(); + }, SEARCH_DELAY_TIME); + } + }); + }; + + onKeydown = (event) => { + if (isHotkey('enter', event)) { + this.onSearch(); + } + }; + + formatQuestionAnsweringItems(data) { + let items = []; + for (let i = 0; i < data.length; i++) { + items[i] = {}; + items[i]['index'] = [i]; + items[i]['name'] = data[i].substring(data[i].lastIndexOf('/')+1); + items[i]['path'] = data[i]; + items[i]['repo_id'] = this.props.repoID; + items[i]['is_dir'] = false; + items[i]['link_content'] = decodeURI(data[i]).substring(1); + items[i]['content'] = data[i].sentence; + items[i]['thumbnail_url'] = ''; + } + return items; + } + + onSearch = () => { + const { indexState, repoID, token } = this.props; + if (indexState === INDEX_STATE.UNCREATED) { + toaster.warning(gettext('Please create index first.')); + return; + } + if (indexState === INDEX_STATE.RUNNING) { + toaster.warning(gettext('Indexing, please try again later.')); + return; + } + if (this.state.isLoading || getValueLength(this.state.value.trim()) < 3) { + return; + } + this.setState({ isLoading: true }); + const searchParams = { + q: this.state.value.trim(), + search_repo: repoID || 'all', + }; + seafileAPI.questionAnsweringFiles(searchParams, token).then(res => { + const { answering_result } = res.data || {}; + const hit_files = answering_result !== 'false' ? res.data.hit_files : []; + this.setState({ + isLoading: false, + answeringResult: answering_result === 'false' ? 'No result' : answering_result, + hitFiles: this.formatQuestionAnsweringItems(hit_files), + }); + }).catch(error => { + /* eslint-disable */ + console.log(error); + this.setState({ isLoading: false }); + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }; + + render() { + return ( +
+
+
+ +
+ + + + {gettext('Return')} +
+ + {this.state.isLoading ? +
+ +
+ : +
+
+ +
+
+
{this.state.answeringResult}
+ + {this.state.hitFiles.length > 0 && + + } +
+
+ } + +
+
+ + + + +
+
+
+
+ ) + } +} diff --git a/frontend/src/components/search/ai-search-widgets/ai-search-help.css b/frontend/src/components/search/ai-search-widgets/ai-search-help.css new file mode 100644 index 00000000000..a33efbc4590 --- /dev/null +++ b/frontend/src/components/search/ai-search-widgets/ai-search-help.css @@ -0,0 +1,25 @@ +.ai-search-help { + padding: 20px 0 20px 0; + border-bottom: 1px solid rgba(0, 40, 100, 0.12); +} + +.ai-search-help .ai-search-help-title { + margin-bottom: 10px; +} + +.ai-search-help .ai-search-help-container { + display: flex; +} + +.ai-search-help .ai-search-help-container .ai-search-help-detail { + border: 1px solid #ccc; + max-width: 200px; + margin-right: 8px; + padding: 4px 8px; + border-radius: 3px; +} + +.ai-search-help .ai-search-help-detail:hover { + cursor: pointer; + background-color: rgb(245, 245, 245); +} diff --git a/frontend/src/components/search/ai-search-widgets/ai-search-help.js b/frontend/src/components/search/ai-search-widgets/ai-search-help.js new file mode 100644 index 00000000000..171b24d83e4 --- /dev/null +++ b/frontend/src/components/search/ai-search-widgets/ai-search-help.js @@ -0,0 +1,23 @@ +import React from 'react'; +import Icon from '../../icon'; +import { gettext } from '../../../utils/constants'; + +import './ai-search-help.css'; + +export default function AISearchHelp() { + return ( +
+
{gettext('Is this answer helpful to you')}{':'}
+
+
+ + {gettext('Yes')} +
+
+ + {gettext('No')} +
+
+
+ ); +} diff --git a/frontend/src/components/search/ai-search-widgets/ai-search-refrences.css b/frontend/src/components/search/ai-search-widgets/ai-search-refrences.css new file mode 100644 index 00000000000..ab8d93ba0a5 --- /dev/null +++ b/frontend/src/components/search/ai-search-widgets/ai-search-refrences.css @@ -0,0 +1,24 @@ +.ai-search-refrences { + margin-top: 10px; +} + +.ai-search-refrences .ai-search-refrences-title { + margin-bottom: 6px; +} + +.ai-search-refrences .ai-search-refrences-container { + display: flex; +} + +.ai-search-refrences .ai-search-refrences-container .ai-search-refrences-detail { + border: 1px solid #ccc; + max-width: 200px; + margin-right: 8px; + padding: 4px 8px; + border-radius: 3px; +} + +.ai-search-refrences .ai-search-refrences-detail:hover { + cursor: pointer; + background-color: rgb(245, 245, 245); +} diff --git a/frontend/src/components/search/ai-search-widgets/ai-search-refrences.js b/frontend/src/components/search/ai-search-widgets/ai-search-refrences.js new file mode 100644 index 00000000000..69108dfc63a --- /dev/null +++ b/frontend/src/components/search/ai-search-widgets/ai-search-refrences.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../../utils/constants'; + +import './ai-search-refrences.css'; + +function AISearchRefrences({hitFiles, onItemClickHandler}) { + return ( +
+
{gettext('Reference documents')}{':'}
+
+ {hitFiles.map((hitFile, index) => { + return ( +
onItemClickHandler(hitFile)} + key={index} + > + {`${index + 1}. ${hitFile.name}`} +
+ ); + })} +
+
+ ); +} + +AISearchRefrences.propTypes = { + hitFiles: PropTypes.array.isRequired, + onItemClickHandler: PropTypes.func.isRequired, +}; + +export default AISearchRefrences; diff --git a/frontend/src/components/search/ai-search-widgets/ai-search-robot.js b/frontend/src/components/search/ai-search-widgets/ai-search-robot.js new file mode 100644 index 00000000000..897eeb92c08 --- /dev/null +++ b/frontend/src/components/search/ai-search-widgets/ai-search-robot.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { mediaUrl } from '../../../utils/constants'; + +function AISearchRobot({style}) { + return ( +
+ +
+ ); +} + +AISearchRobot.propTypes = { + style: PropTypes.object, +}; + +export default AISearchRobot; diff --git a/frontend/src/components/search/ai-search.js b/frontend/src/components/search/ai-search.js index 44062107586..02026408072 100644 --- a/frontend/src/components/search/ai-search.js +++ b/frontend/src/components/search/ai-search.js @@ -1,6 +1,7 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import isHotkey from 'is-hotkey'; +import classnames from 'classnames'; import MediaQuery from 'react-responsive'; import { seafileAPI } from '../../utils/seafile-api'; import { gettext, siteRoot } from '../../utils/constants'; @@ -9,7 +10,9 @@ import { Utils } from '../../utils/utils'; import { isMac } from '../../utils/extra-attributes'; import toaster from '../toast'; import Switch from '../common/switch'; -import { SEARCH_DELAY_TIME } from './constant'; +import { SEARCH_DELAY_TIME, getValueLength } from './constant'; +import AISearchAsk from './ai-search-ask'; +import AISearchRobot from './ai-search-widgets/ai-search-robot'; const INDEX_STATE = { RUNNING: 'running', @@ -17,6 +20,11 @@ const INDEX_STATE = { FINISHED: 'finished' }; +const SEARCH_MODE = { + QA: 'question-answering', + COMBINED: 'combined-search', +}; + const PER_PAGE = 10; const controlKey = isMac() ? '⌘' : 'Ctrl'; @@ -47,6 +55,7 @@ export default class AISearch extends Component { isSearchInputShow: false, // for mobile searchPageUrl: this.baseSearchPageURL, indexState: '', + searchMode: SEARCH_MODE.COMBINED, }; this.inputValue = ''; this.highlightRef = null; @@ -84,33 +93,31 @@ export default class AISearch extends Component { document.removeEventListener('compositionstart', this.onCompositionStart); document.removeEventListener('compositionend', this.onCompositionEnd); this.isChineseInput = false; - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + this.clearTimer(); if (this.indexStateTimer) { clearInterval(this.indexStateTimer); this.indexStateTimer = null; } } - onCompositionStart = () => { - this.isChineseInput = true; + clearTimer = () => { if (this.timer) { clearTimeout(this.timer); this.timer = null; } }; + onCompositionStart = () => { + this.isChineseInput = true; + this.clearTimer(); + }; + onCompositionEnd = () => { this.isChineseInput = false; // chrome:compositionstart -> onChange -> compositionend // not chrome:compositionstart -> compositionend -> onChange // The onChange event will setState and change input value, then setTimeout to initiate the search - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + this.clearTimer(); this.timer = setTimeout(() => { this.onSearch(); }, SEARCH_DELAY_TIME); @@ -199,10 +206,7 @@ export default class AISearch extends Component { if (this.inputValue === newValue.trim()) return; this.inputValue = newValue.trim(); if (!this.isChineseInput) { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } + this.clearTimer(); this.timer = setTimeout(() => { this.onSearch(); }, SEARCH_DELAY_TIME); @@ -219,7 +223,7 @@ export default class AISearch extends Component { onSearch = () => { const { value } = this.state; const { repoID } = this.props; - if (this.inputValue === '' || this.getValueLength(this.inputValue) < 3) { + if (this.inputValue === '' || getValueLength(this.inputValue) < 3) { this.setState({ highlightIndex: 0, resultItems: [], @@ -329,6 +333,9 @@ export default class AISearch extends Component { hasMore: false, }); }).catch(error => { + if (error && error.message === "prev request is cancelled") { + return; + } let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); this.setState({ isLoading: false }); @@ -362,23 +369,6 @@ export default class AISearch extends Component { this.setState({searchPageUrl: `${this.baseSearchPageURL}?${params.substring(0, params.length - 1)}`}); } - getValueLength(str) { - var i = 0, code, len = 0; - for (; i < str.length; i++) { - code = str.charCodeAt(i); - if (code == 10) { //solve enter problem - len += 2; - } else if (code < 0x007f) { - len += 1; - } else if (code >= 0x0080 && code <= 0x07ff) { - len += 2; - } else if (code >= 0x0800 && code <= 0xffff) { - len += 3; - } - } - return len; - } - formatResultItems(data) { let items = []; for (let i = 0; i < data.length; i++) { @@ -429,26 +419,55 @@ export default class AISearch extends Component { }); } + openAsk = () => { + this.clearTimer(); + this.setState({ searchMode: SEARCH_MODE.QA }); + } + + closeAsk = () => { + this.clearTimer(); + this.setState({ searchMode: SEARCH_MODE.COMBINED }); + } + renderSearchResult() { - const { resultItems, highlightIndex, width } = this.state; + const { resultItems, highlightIndex, width, searchMode, answeringResult } = this.state; if (!width || width === 'default') return null; if (!this.state.isResultShow) return null; - if (!this.state.isResultGetted || this.getValueLength(this.inputValue) < 3) { + if (!this.state.isResultGetted || getValueLength(this.inputValue) < 3) { return ( ); } if (!resultItems.length) { return ( -
{gettext('No results matching.')}
+ <> +
  • + +
    +
    {gettext('Ask AI')}{': '}{this.state.value.trim()}
    +
    +
  • +
    {gettext('No results matching.')}
    + ); } const results = (