diff --git a/public/components/edit_conversation_name_modal.tsx b/public/components/edit_conversation_name_modal.tsx new file mode 100644 index 00000000..76991007 --- /dev/null +++ b/public/components/edit_conversation_name_modal.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useRef } from 'react'; + +import { EuiConfirmModal, EuiFieldText, EuiSpacer, EuiText } from '@elastic/eui'; +import { usePatchSession } from '../hooks/use_sessions'; + +interface EditConversationNameModalProps { + onClose?: (status: 'updated' | 'cancelled' | 'errored') => void; + sessionId: string; + defaultTitle: string; +} + +export const EditConversationNameModal = ({ + onClose, + sessionId, + defaultTitle, +}: EditConversationNameModalProps) => { + const titleInputRef = useRef(null); + const { loading, abortController, patchSession } = usePatchSession(); + + const handleCancel = useCallback(() => { + abortController?.abort(); + onClose?.('cancelled'); + }, [onClose, abortController]); + const handleConfirm = useCallback(async () => { + const title = titleInputRef.current?.value.trim(); + if (!title) { + return; + } + try { + await patchSession(sessionId, title); + } catch (_e) { + onClose?.('errored'); + return; + } + onClose?.('updated'); + }, [onClose, sessionId, patchSession]); + + return ( + + +

Please enter a new name for your conversation.

+
+ + +
+ ); +}; diff --git a/public/hooks/fetch_reducer.ts b/public/hooks/fetch_reducer.ts index 35b2f888..6cbd88b6 100644 --- a/public/hooks/fetch_reducer.ts +++ b/public/hooks/fetch_reducer.ts @@ -34,3 +34,40 @@ export const genericReducer: GenericReducer = (state, action) => { return state; } }; + +interface StateWithAbortController { + data?: T; + loading: boolean; + error?: Error; + abortController?: AbortController; +} + +type ActionWithAbortController = + | { type: 'request'; abortController: AbortController } + | { type: 'success'; payload: State['data'] } + | { + type: 'failure'; + error: NonNullable['error']> | { body: NonNullable['error']> }; + }; + +// TODO use instantiation expressions when typescript is upgraded to >= 4.7 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GenericReducerWithAbortController = Reducer< + StateWithAbortController, + ActionWithAbortController +>; +export const genericReducerWithAbortController: GenericReducerWithAbortController = ( + state, + action +) => { + switch (action.type) { + case 'request': + return { data: state.data, loading: true, abortController: action.abortController }; + case 'success': + return { loading: false, data: action.payload }; + case 'failure': + return { loading: false, error: 'body' in action.error ? action.error.body : action.error }; + default: + return state; + } +}; diff --git a/public/hooks/use_sessions.ts b/public/hooks/use_sessions.ts index ab8992f7..f5ebe0ae 100644 --- a/public/hooks/use_sessions.ts +++ b/public/hooks/use_sessions.ts @@ -3,13 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useEffect, useReducer, useState } from 'react'; +import { useCallback, useEffect, useReducer, useState } from 'react'; import { HttpFetchQuery, SavedObjectsFindOptions } from '../../../../src/core/public'; import { ASSISTANT_API } from '../../common/constants/llm'; import { ISession, ISessionFindResponse } from '../../common/types/chat_saved_object_attributes'; import { useChatContext } from '../contexts/chat_context'; import { useCore } from '../contexts/core_context'; -import { GenericReducer, genericReducer } from './fetch_reducer'; +import { GenericReducer, genericReducer, genericReducerWithAbortController } from './fetch_reducer'; export const useGetSession = () => { const chatContext = useChatContext(); @@ -65,3 +65,48 @@ export const useGetSessions = (options: Partial = {}) = return { ...state, refresh: () => setRefresh({}) }; }; + +export const useDeleteSession = () => { + const core = useCore(); + const [state, dispatch] = useReducer(genericReducerWithAbortController, { loading: false }); + + const deleteSession = useCallback((sessionId: string) => { + const abortController = new AbortController(); + dispatch({ type: 'request', abortController }); + return core.services.http + .delete(`${ASSISTANT_API.SESSION}/${sessionId}`, { + signal: abortController.signal, + }) + .then((payload) => dispatch({ type: 'success', payload })) + .catch((error) => dispatch({ type: 'failure', error })); + }, []); + + return { + ...state, + deleteSession, + }; +}; + +export const usePatchSession = () => { + const core = useCore(); + const [state, dispatch] = useReducer(genericReducerWithAbortController, { loading: false }); + + const patchSession = useCallback((sessionId: string, title: string) => { + const abortController = new AbortController(); + dispatch({ type: 'request', abortController }); + return core.services.http + .patch(`${ASSISTANT_API.SESSION}/${sessionId}`, { + query: { + title, + }, + signal: abortController.signal, + }) + .then((payload) => dispatch({ type: 'success', payload })) + .catch((error) => dispatch({ type: 'failure', error })); + }, []); + + return { + ...state, + patchSession, + }; +}; diff --git a/public/tabs/history/chat_history_list.tsx b/public/tabs/history/chat_history_list.tsx new file mode 100644 index 00000000..9707ff67 --- /dev/null +++ b/public/tabs/history/chat_history_list.tsx @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiPanel, + EuiText, +} from '@elastic/eui'; +import moment from 'moment'; + +interface ChatHistory { + id: string; + title: string; + updatedTimeMs: number; +} + +interface ChatHistoryListItemProps extends ChatHistory { + hasBottomBorder?: boolean; + onTitleClick?: (id: string, title: string) => void; + onDeleteClick?: (conversation: { id: string }) => void; + onEditClick?: (conversation: { id: string; title: string }) => void; +} + +export const ChatHistoryListItem = ({ + id, + title, + updatedTimeMs, + hasBottomBorder = true, + onTitleClick, + onDeleteClick, + onEditClick, +}: ChatHistoryListItemProps) => { + const handleTitleClick = useCallback(() => { + onTitleClick?.(id, title); + }, [onTitleClick, id, title]); + + const handleDeleteClick = useCallback(() => { + onDeleteClick?.({ id }); + }, [onDeleteClick, id]); + + const handleEditClick = useCallback(() => { + onEditClick?.({ id, title }); + }, [onEditClick, id, title]); + + return ( + <> + + + + +

+ {title} +

+
+
+ + {moment(updatedTimeMs).format('MMMM D, YYYY')} at{' '} + {moment(updatedTimeMs).format('h:m A')} + +
+ + + + + + + + + + +
+ {hasBottomBorder && } + + ); +}; + +export interface ChatHistoryListProps { + chatHistories: ChatHistory[]; + onChatHistoryTitleClick?: (id: string, title: string) => void; + onChatHistoryDeleteClick?: (conversation: { id: string }) => void; + onChatHistoryEditClick?: (conversation: { id: string; title: string }) => void; +} + +export const ChatHistoryList = ({ + chatHistories, + onChatHistoryTitleClick, + onChatHistoryEditClick, + onChatHistoryDeleteClick, +}: ChatHistoryListProps) => { + return ( + <> + + {chatHistories.map((item, index) => ( + + ))} + + + ); +}; diff --git a/public/tabs/history/chat_history_page.tsx b/public/tabs/history/chat_history_page.tsx index 212230dc..66d63072 100644 --- a/public/tabs/history/chat_history_page.tsx +++ b/public/tabs/history/chat_history_page.tsx @@ -4,99 +4,135 @@ */ import { - CriteriaWithPagination, - Direction, - EuiBasicTable, - EuiBasicTableColumn, + EuiFieldSearch, EuiFlyoutBody, - EuiLink, EuiPage, EuiPageBody, - EuiText, + EuiSpacer, + EuiTablePagination, + EuiTitle, } from '@elastic/eui'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { useDebounce } from 'react-use'; import { SavedObjectsFindOptions } from '../../../../../src/core/public'; -import { ISessionFindResponse } from '../../../common/types/chat_saved_object_attributes'; import { useChatActions } from '../../hooks/use_chat_actions'; import { useGetSessions } from '../../hooks/use_sessions'; +import { ChatHistoryList } from './chat_history_list'; +import { EditConversationNameModal } from '../../components/edit_conversation_name_modal'; +import { DeleteConversationConfirmModal } from './delete_conversation_confirm_modal'; interface ChatHistoryPageProps { shouldRefresh: boolean; className?: string; } -type ItemType = ISessionFindResponse['objects'][number]; - export const ChatHistoryPage: React.FC = (props) => { const { loadChat } = useChatActions(); + const [editingConversation, setEditingConversation] = useState<{ + id: string; + title: string; + } | null>(null); + const [deletingConversation, setDeletingConversation] = useState<{ id: string } | null>(null); const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(20); - const [sortOrder, setSortOrder] = useState('desc'); - const [sortField, setSortField] = useState('updatedTimeMs'); + const [pageSize, setPageSize] = useState(10); + const [searchName, setSearchName] = useState(); + const [debouncedSearchName, setDebouncedSearchName] = useState(); const bulkGetOptions: Partial = useMemo( () => ({ page: pageIndex + 1, perPage: pageSize, - sortOrder, - sortField, fields: ['createdTimeMs', 'updatedTimeMs', 'title'], + ...(debouncedSearchName ? { search: debouncedSearchName, searchFields: ['title'] } : {}), }), - [pageIndex, pageSize, sortOrder, sortField] + [pageIndex, pageSize, debouncedSearchName] ); - const { data: sessions, loading, error, refresh } = useGetSessions(bulkGetOptions); + const { data: sessions, refresh } = useGetSessions(bulkGetOptions); - useEffect(() => { - if (props.shouldRefresh) refresh(); - }, [props.shouldRefresh]); + const chatHistories = useMemo(() => sessions?.objects || [], [sessions]); - const onTableChange = (criteria: CriteriaWithPagination) => { - const { index, size } = criteria.page; - setPageIndex(index); - setPageSize(size); - if (criteria.sort) { - const { field, direction } = criteria.sort; - setSortField(field); - setSortOrder(direction); - } - }; + const handleEditConversationCancel = useCallback( + (status: 'updated' | string) => { + if (status === 'updated') { + refresh(); + } + setEditingConversation(null); + }, + [setEditingConversation] + ); - const columns: Array> = [ - { - field: 'id', - name: 'Chat', - render: (id: string, item) => ( - loadChat(id, item.title)}>{item.title} - ), + const handleDeleteConversationCancel = useCallback( + (status: 'deleted' | string) => { + if (status === 'deleted') { + refresh(); + } + setDeletingConversation(null); }, - { - field: 'updatedTimeMs', - name: 'Updated Time', - sortable: true, - render: (updatedTimeMs: number) => ( - {new Date(updatedTimeMs).toLocaleString()} - ), + [setDeletingConversation, refresh] + ); + + const handleSearchChange = useCallback((e) => { + setSearchName(e.target.value); + }, []); + + useDebounce( + () => { + setPageIndex(0); + setDebouncedSearchName(searchName); }, - ]; + 150, + [searchName] + ); + + useEffect(() => { + if (props.shouldRefresh) refresh(); + }, [props.shouldRefresh]); return ( - +

+ +

+ + + + + + + + + {editingConversation && ( + + )} + {deletingConversation && ( + + )}
diff --git a/public/tabs/history/delete_conversation_confirm_modal.tsx b/public/tabs/history/delete_conversation_confirm_modal.tsx new file mode 100644 index 00000000..adddd957 --- /dev/null +++ b/public/tabs/history/delete_conversation_confirm_modal.tsx @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; + +import { EuiConfirmModal, EuiText } from '@elastic/eui'; + +import { useDeleteSession } from '../../hooks/use_sessions'; + +interface DeleteConversationConfirmModalProps { + onClose?: (status: 'canceled' | 'errored' | 'deleted') => void; + sessionId: string; +} + +export const DeleteConversationConfirmModal = ({ + onClose, + sessionId, +}: DeleteConversationConfirmModalProps) => { + const { loading, data, deleteSession, abortController } = useDeleteSession(); + + const handleCancel = useCallback(() => { + abortController?.abort(); + onClose?.('canceled'); + }, [onClose, abortController]); + const handleConfirm = useCallback(async () => { + try { + await deleteSession(sessionId); + } catch (_e) { + onClose?.('errored'); + return; + } + onClose?.('deleted'); + }, [onClose, deleteSession]); + + return ( + + +

+ Are you sure you want to delete the conversation? After it’s deleted, the conversation + details will not be accessible. +

+
+
+ ); +}; diff --git a/server/routes/chat_routes.ts b/server/routes/chat_routes.ts index 8382dd2a..906e16ab 100644 --- a/server/routes/chat_routes.ts +++ b/server/routes/chat_routes.ts @@ -53,11 +53,34 @@ const getSessionsRoute = { sortOrder: schema.maybe(schema.string()), sortField: schema.maybe(schema.string()), fields: schema.maybe(schema.arrayOf(schema.string())), + search: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), }), }, }; export type GetSessionsSchema = TypeOf; +const deleteSessionRoute = { + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + validate: { + params: schema.object({ + sessionId: schema.string(), + }), + }, +}; + +const updateSessionRoute = { + path: `${ASSISTANT_API.SESSION}/{sessionId}`, + validate: { + params: schema.object({ + sessionId: schema.string(), + }), + query: schema.object({ + title: schema.string(), + }), + }, +}; + export function registerChatRoutes(router: IRouter) { const createStorageService = (context: RequestHandlerContext) => new SavedObjectsStorageService(context.core.savedObjects.client); @@ -139,4 +162,45 @@ export function registerChatRoutes(router: IRouter) { } } ); + + router.delete( + deleteSessionRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.deleteSession(request.params.sessionId); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); + + router.patch( + updateSessionRoute, + async ( + context, + request, + response + ): Promise> => { + const storageService = createStorageService(context); + + try { + const getResponse = await storageService.updateSession( + request.params.sessionId, + request.query.title + ); + return response.ok({ body: getResponse }); + } catch (error) { + context.assistant_plugin.logger.error(error); + return response.custom({ statusCode: error.statusCode || 500, body: error.message }); + } + } + ); } diff --git a/server/services/storage/saved_objects_storage_service.ts b/server/services/storage/saved_objects_storage_service.ts index 34b45296..78fcffb4 100644 --- a/server/services/storage/saved_objects_storage_service.ts +++ b/server/services/storage/saved_objects_storage_service.ts @@ -38,6 +38,8 @@ export class SavedObjectsStorageService implements StorageService { // saved objects by default provides updated_at field ...(query.sortField === 'updatedTimeMs' && { sortField: 'updated_at' }), type: CHAT_SAVED_OBJECT, + searchFields: + typeof query.searchFields === 'string' ? [query.searchFields] : query.searchFields, }); return { objects: sessions.saved_objects.map((session) => ({ @@ -75,4 +77,14 @@ export class SavedObjectsStorageService implements StorageService { ); return { sessionId, messages: updateResponse.attributes.messages! }; } + + deleteSession(sessionId: string) { + return this.client.delete(CHAT_SAVED_OBJECT, sessionId); + } + + updateSession(sessionId: string, title: string) { + return this.client.update(CHAT_SAVED_OBJECT, sessionId, { + title, + }); + } }