From be26d31e38b6058bb7cfe3d8190b4ea8861c8b63 Mon Sep 17 00:00:00 2001 From: Hiroshi Fujita Date: Sat, 5 Oct 2024 01:37:12 +0900 Subject: [PATCH] Add chat history feature (#1988) * Add chat history feature This commit adds the following new files: - HistoryItem: A new component for displaying chat history items. - HistoryPanel: A new component for displaying the chat history panel. - HistoryButton: A new component for opening the chat history. - HistoryProviders: A new module containing history provider classes. These changes include functionality to support future enhancements to the chat history feature. * Add translations for chat history in Spanish and French * Update e2e test * Refactor chat history configuration variables Changed variable names to make their functions clearer. * Refactor session state handling Changed to generate session state on the server side. * Add support for chat history feature to bicep * Update workflows, docs, typing * Apply suggestions from code review Co-authored-by: Wassim Chegham * Revert e2e test change --------- Co-authored-by: Pamela Fox Co-authored-by: Wassim Chegham Co-authored-by: Pamela Fox --- .azdo/pipelines/azure-dev.yml | 2 +- .github/workflows/azure-dev.yml | 2 +- CONTRIBUTING.md | 1 + app/backend/app.py | 19 +- app/backend/config.py | 1 + app/backend/core/sessionhelper.py | 8 + app/frontend/package-lock.json | 6 + app/frontend/package.json | 1 + app/frontend/src/api/models.ts | 1 + .../HistoryButton/HistoryButton.module.css | 7 + .../HistoryButton/HistoryButton.tsx | 22 +++ .../src/components/HistoryButton/index.tsx | 1 + .../HistoryItem/HistoryItem.module.css | 120 +++++++++++++ .../components/HistoryItem/HistoryItem.tsx | 59 +++++++ .../src/components/HistoryItem/index.tsx | 1 + .../HistoryPanel/HistoryPanel.module.css | 14 ++ .../components/HistoryPanel/HistoryPanel.tsx | 164 ++++++++++++++++++ .../src/components/HistoryPanel/index.tsx | 1 + .../HistoryProviders/HistoryManager.ts | 18 ++ .../components/HistoryProviders/IProvider.ts | 18 ++ .../components/HistoryProviders/IndexedDB.ts | 104 +++++++++++ .../src/components/HistoryProviders/None.ts | 20 +++ .../src/components/HistoryProviders/index.ts | 4 + app/frontend/src/locales/en/translation.json | 13 ++ app/frontend/src/locales/es/translation.json | 13 ++ app/frontend/src/locales/fr/translation.json | 13 ++ app/frontend/src/locales/ja/translation.json | 13 ++ app/frontend/src/pages/chat/Chat.module.css | 9 + app/frontend/src/pages/chat/Chat.tsx | 44 ++++- azure.yaml | 1 + docs/deploy_features.md | 11 ++ infra/main.bicep | 3 + infra/main.parameters.json | 3 + 33 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 app/backend/core/sessionhelper.py create mode 100644 app/frontend/src/components/HistoryButton/HistoryButton.module.css create mode 100644 app/frontend/src/components/HistoryButton/HistoryButton.tsx create mode 100644 app/frontend/src/components/HistoryButton/index.tsx create mode 100644 app/frontend/src/components/HistoryItem/HistoryItem.module.css create mode 100644 app/frontend/src/components/HistoryItem/HistoryItem.tsx create mode 100644 app/frontend/src/components/HistoryItem/index.tsx create mode 100644 app/frontend/src/components/HistoryPanel/HistoryPanel.module.css create mode 100644 app/frontend/src/components/HistoryPanel/HistoryPanel.tsx create mode 100644 app/frontend/src/components/HistoryPanel/index.tsx create mode 100644 app/frontend/src/components/HistoryProviders/HistoryManager.ts create mode 100644 app/frontend/src/components/HistoryProviders/IProvider.ts create mode 100644 app/frontend/src/components/HistoryProviders/IndexedDB.ts create mode 100644 app/frontend/src/components/HistoryProviders/None.ts create mode 100644 app/frontend/src/components/HistoryProviders/index.ts diff --git a/.azdo/pipelines/azure-dev.yml b/.azdo/pipelines/azure-dev.yml index 3498c25dc7..c5fe8319c1 100644 --- a/.azdo/pipelines/azure-dev.yml +++ b/.azdo/pipelines/azure-dev.yml @@ -111,7 +111,7 @@ steps: AZURE_ADLS_GEN2_FILESYSTEM: $(AZURE_ADLS_GEN2_FILESYSTEM) DEPLOYMENT_TARGET: $(DEPLOYMENT_TARGET) AZURE_CONTAINER_APPS_WORKLOAD_PROFILE: $(AZURE_CONTAINER_APPS_WORKLOAD_PROFILE) - + USE_CHAT_HISTORY_BROWSER: $(USE_CHAT_HISTORY_BROWSER) - task: AzureCLI@2 displayName: Deploy Application inputs: diff --git a/.github/workflows/azure-dev.yml b/.github/workflows/azure-dev.yml index d414609eb1..40ab577028 100644 --- a/.github/workflows/azure-dev.yml +++ b/.github/workflows/azure-dev.yml @@ -95,7 +95,7 @@ jobs: AZURE_ADLS_GEN2_FILESYSTEM: ${{ vars.AZURE_ADLS_GEN2_FILESYSTEM }} DEPLOYMENT_TARGET: ${{ vars.DEPLOYMENT_TARGET }} AZURE_CONTAINER_APPS_WORKLOAD_PROFILE: ${{ vars.AZURE_CONTAINER_APPS_WORKLOAD_PROFILE }} - + USE_CHAT_HISTORY_BROWSER: ${{ vars.USE_CHAT_HISTORY_BROWSER }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d734be650d..55b7be4e4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,7 @@ If you followed the steps above to install the pre-commit hooks, then you can ju ## Adding new azd environment variables When adding new azd environment variables, please remember to update: + 1. App Service's [azure.yaml](./azure.yaml) 1. [ADO pipeline](.azdo/pipelines/azure-dev.yml). 1. [Github workflows](.github/workflows/azure-dev.yml) diff --git a/app/backend/app.py b/app/backend/app.py index 386ce6881a..d44d4bc9c5 100644 --- a/app/backend/app.py +++ b/app/backend/app.py @@ -59,6 +59,7 @@ CONFIG_AUTH_CLIENT, CONFIG_BLOB_CONTAINER_CLIENT, CONFIG_CHAT_APPROACH, + CONFIG_CHAT_HISTORY_BROWSER_ENABLED, CONFIG_CHAT_VISION_APPROACH, CONFIG_CREDENTIAL, CONFIG_GPT4V_DEPLOYED, @@ -79,6 +80,7 @@ CONFIG_VECTOR_SEARCH_ENABLED, ) from core.authentication import AuthenticationHelper +from core.sessionhelper import create_session_id from decorators import authenticated, authenticated_path from error import error_dict, error_response from prepdocs import ( @@ -218,10 +220,15 @@ async def chat(auth_claims: Dict[str, Any]): else: approach = cast(Approach, current_app.config[CONFIG_CHAT_APPROACH]) + # If session state is provided, persists the session state, + # else creates a new session_id depending on the chat history options enabled. + session_state = request_json.get("session_state") + if session_state is None: + session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED]) result = await approach.run( request_json["messages"], context=context, - session_state=request_json.get("session_state"), + session_state=session_state, ) return jsonify(result) except Exception as error: @@ -244,10 +251,15 @@ async def chat_stream(auth_claims: Dict[str, Any]): else: approach = cast(Approach, current_app.config[CONFIG_CHAT_APPROACH]) + # If session state is provided, persists the session state, + # else creates a new session_id depending on the chat history options enabled. + session_state = request_json.get("session_state") + if session_state is None: + session_state = create_session_id(current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED]) result = await approach.run_stream( request_json["messages"], context=context, - session_state=request_json.get("session_state"), + session_state=session_state, ) response = await make_response(format_as_ndjson(result)) response.timeout = None # type: ignore @@ -276,6 +288,7 @@ def config(): "showSpeechInput": current_app.config[CONFIG_SPEECH_INPUT_ENABLED], "showSpeechOutputBrowser": current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED], "showSpeechOutputAzure": current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED], + "showChatHistoryBrowser": current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED], } ) @@ -439,6 +452,7 @@ async def setup_clients(): USE_SPEECH_INPUT_BROWSER = os.getenv("USE_SPEECH_INPUT_BROWSER", "").lower() == "true" USE_SPEECH_OUTPUT_BROWSER = os.getenv("USE_SPEECH_OUTPUT_BROWSER", "").lower() == "true" USE_SPEECH_OUTPUT_AZURE = os.getenv("USE_SPEECH_OUTPUT_AZURE", "").lower() == "true" + USE_CHAT_HISTORY_BROWSER = os.getenv("USE_CHAT_HISTORY_BROWSER", "").lower() == "true" # WEBSITE_HOSTNAME is always set by App Service, RUNNING_IN_PRODUCTION is set in main.bicep RUNNING_ON_AZURE = os.getenv("WEBSITE_HOSTNAME") is not None or os.getenv("RUNNING_IN_PRODUCTION") is not None @@ -609,6 +623,7 @@ async def setup_clients(): current_app.config[CONFIG_SPEECH_INPUT_ENABLED] = USE_SPEECH_INPUT_BROWSER current_app.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED] = USE_SPEECH_OUTPUT_BROWSER current_app.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED] = USE_SPEECH_OUTPUT_AZURE + current_app.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED] = USE_CHAT_HISTORY_BROWSER # Various approaches to integrate GPT and external knowledge, most applications will use a single one of these patterns # or some derivative, here we include several for exploration purposes diff --git a/app/backend/config.py b/app/backend/config.py index bedc3e27be..e5f274fa38 100644 --- a/app/backend/config.py +++ b/app/backend/config.py @@ -22,3 +22,4 @@ CONFIG_SPEECH_SERVICE_LOCATION = "speech_service_location" CONFIG_SPEECH_SERVICE_TOKEN = "speech_service_token" CONFIG_SPEECH_SERVICE_VOICE = "speech_service_voice" +CONFIG_CHAT_HISTORY_BROWSER_ENABLED = "chat_history_browser_enabled" diff --git a/app/backend/core/sessionhelper.py b/app/backend/core/sessionhelper.py new file mode 100644 index 0000000000..28dd0e811b --- /dev/null +++ b/app/backend/core/sessionhelper.py @@ -0,0 +1,8 @@ +import uuid +from typing import Union + + +def create_session_id(config_chat_history_browser_enabled: bool) -> Union[str, None]: + if config_chat_history_browser_enabled: + return str(uuid.uuid4()) + return None diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 1f09a3b7df..330b517c7f 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -18,6 +18,7 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", + "idb": "^8.0.0", "ndjson-readablestream": "^1.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3813,6 +3814,11 @@ "cross-fetch": "4.0.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + }, "node_modules/inline-style-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index c715ad0cad..781c9468bd 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -22,6 +22,7 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", + "idb": "^8.0.0", "ndjson-readablestream": "^1.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/app/frontend/src/api/models.ts b/app/frontend/src/api/models.ts index c2899e3948..1829ab5312 100644 --- a/app/frontend/src/api/models.ts +++ b/app/frontend/src/api/models.ts @@ -89,6 +89,7 @@ export type Config = { showSpeechInput: boolean; showSpeechOutputBrowser: boolean; showSpeechOutputAzure: boolean; + showChatHistoryBrowser: boolean; }; export type SimpleAPIResponse = { diff --git a/app/frontend/src/components/HistoryButton/HistoryButton.module.css b/app/frontend/src/components/HistoryButton/HistoryButton.module.css new file mode 100644 index 0000000000..8d998d2d84 --- /dev/null +++ b/app/frontend/src/components/HistoryButton/HistoryButton.module.css @@ -0,0 +1,7 @@ +.container { + display: flex; + align-items: center; + gap: 0.375em; + cursor: pointer; + padding: 0.5rem; +} diff --git a/app/frontend/src/components/HistoryButton/HistoryButton.tsx b/app/frontend/src/components/HistoryButton/HistoryButton.tsx new file mode 100644 index 0000000000..d52a55daf1 --- /dev/null +++ b/app/frontend/src/components/HistoryButton/HistoryButton.tsx @@ -0,0 +1,22 @@ +import { History24Regular } from "@fluentui/react-icons"; +import { Button } from "@fluentui/react-components"; +import { useTranslation } from "react-i18next"; + +import styles from "./HistoryButton.module.css"; + +interface Props { + className?: string; + onClick: () => void; + disabled?: boolean; +} + +export const HistoryButton = ({ className, disabled, onClick }: Props) => { + const { t } = useTranslation(); + return ( +
+ +
+ ); +}; diff --git a/app/frontend/src/components/HistoryButton/index.tsx b/app/frontend/src/components/HistoryButton/index.tsx new file mode 100644 index 0000000000..7d6ff3cfcc --- /dev/null +++ b/app/frontend/src/components/HistoryButton/index.tsx @@ -0,0 +1 @@ +export * from "./HistoryButton"; diff --git a/app/frontend/src/components/HistoryItem/HistoryItem.module.css b/app/frontend/src/components/HistoryItem/HistoryItem.module.css new file mode 100644 index 0000000000..74245957e9 --- /dev/null +++ b/app/frontend/src/components/HistoryItem/HistoryItem.module.css @@ -0,0 +1,120 @@ +.historyItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 6px; + transition: background-color 0.2s; +} + +.historyItem:hover { + background-color: #f3f4f6; +} + +.historyItemButton { + flex-grow: 1; + text-align: left; + padding: 0; + margin-right: 4px; + background: none; + border: none; + cursor: pointer; +} + +.historyItemTitle { + font-size: 1rem; +} + +.deleteIcon { + width: 20px; + height: 20px; +} + +.deleteButton { + opacity: 0; + transition: opacity 0.2s; + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 9999px; + color: #6b7280; +} + +.historyItem:hover .deleteButton, +.deleteButton:focus { + opacity: 1; +} + +.deleteButton:hover { + color: #111827; +} + +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.modalContent { + background-color: white; + padding: 24px; + border-radius: 8px; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + max-width: 400px; + width: 100%; +} + +.modalTitle { + font-size: 20px; + font-weight: 600; + margin-top: 0px; + margin-bottom: 16px; +} + +.modalDescription { + margin-top: 0px; + margin-bottom: 16px; +} + +.modalActions { + display: flex; + justify-content: flex-end; + gap: 16px; +} + +.modalCancelButton, +.modalConfirmButton { + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.modalCancelButton { + background-color: #f3f4f6; + color: #374151; +} + +.modalConfirmButton { + background-color: #ef4444; + color: white; +} + +.modalCancelButton:hover { + background-color: #e5e7eb; +} + +.modalConfirmButton:hover { + background-color: #dc2626; +} diff --git a/app/frontend/src/components/HistoryItem/HistoryItem.tsx b/app/frontend/src/components/HistoryItem/HistoryItem.tsx new file mode 100644 index 0000000000..5aca674579 --- /dev/null +++ b/app/frontend/src/components/HistoryItem/HistoryItem.tsx @@ -0,0 +1,59 @@ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import styles from "./HistoryItem.module.css"; +import { DefaultButton } from "@fluentui/react"; +import { Delete24Regular } from "@fluentui/react-icons"; + +export interface HistoryData { + id: string; + title: string; + timestamp: number; +} + +interface HistoryItemProps { + item: HistoryData; + onSelect: (id: string) => void; + onDelete: (id: string) => void; +} + +export function HistoryItem({ item, onSelect, onDelete }: HistoryItemProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleDelete = useCallback(() => { + setIsModalOpen(false); + onDelete(item.id); + }, [item.id, onDelete]); + + return ( +
+ + + setIsModalOpen(false)} onConfirm={handleDelete} /> +
+ ); +} + +function DeleteHistoryModal({ isOpen, onClose, onConfirm }: { isOpen: boolean; onClose: () => void; onConfirm: () => void }) { + if (!isOpen) return null; + const { t } = useTranslation(); + return ( +
+
+

{t("history.deleteModalTitle")}

+

{t("history.deleteModalDescription")}

+
+ + {t("history.cancelLabel")} + + + {t("history.deleteLabel")} + +
+
+
+ ); +} diff --git a/app/frontend/src/components/HistoryItem/index.tsx b/app/frontend/src/components/HistoryItem/index.tsx new file mode 100644 index 0000000000..314bd98059 --- /dev/null +++ b/app/frontend/src/components/HistoryItem/index.tsx @@ -0,0 +1 @@ +export * from "./HistoryItem"; diff --git a/app/frontend/src/components/HistoryPanel/HistoryPanel.module.css b/app/frontend/src/components/HistoryPanel/HistoryPanel.module.css new file mode 100644 index 0000000000..05b8d07848 --- /dev/null +++ b/app/frontend/src/components/HistoryPanel/HistoryPanel.module.css @@ -0,0 +1,14 @@ +.group { + margin-top: 1rem; +} +.groupLabel { + font-size: 0.9rem; + font-weight: bold; + margin-top: 0.5rem; + margin-bottom: 0.2rem; +} + +.footer { + display: flex; + justify-content: space-between; +} diff --git a/app/frontend/src/components/HistoryPanel/HistoryPanel.tsx b/app/frontend/src/components/HistoryPanel/HistoryPanel.tsx new file mode 100644 index 0000000000..b70a723305 --- /dev/null +++ b/app/frontend/src/components/HistoryPanel/HistoryPanel.tsx @@ -0,0 +1,164 @@ +import { Panel, PanelType } from "@fluentui/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { HistoryData, HistoryItem } from "../HistoryItem"; +import { Answers, HistoryProviderOptions } from "../HistoryProviders/IProvider"; +import { useHistoryManager, HistoryMetaData } from "../HistoryProviders"; +import { useTranslation } from "react-i18next"; +import styles from "./HistoryPanel.module.css"; + +const HISTORY_COUNT_PER_LOAD = 20; + +export const HistoryPanel = ({ + provider, + isOpen, + notify, + onClose, + onChatSelected +}: { + provider: HistoryProviderOptions; + isOpen: boolean; + notify: boolean; + onClose: () => void; + onChatSelected: (answers: Answers) => void; +}) => { + const historyManager = useHistoryManager(provider); + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasMoreHistory, setHasMoreHistory] = useState(false); + + useEffect(() => { + if (!isOpen) return; + if (notify) { + setHistory([]); + historyManager.resetContinuationToken(); + setHasMoreHistory(true); + } + }, [isOpen, notify]); + + const loadMoreHistory = async () => { + setIsLoading(() => true); + const items = await historyManager.getNextItems(HISTORY_COUNT_PER_LOAD); + if (items.length === 0) { + setHasMoreHistory(false); + } + setHistory(prevHistory => [...prevHistory, ...items]); + setIsLoading(() => false); + }; + + const handleSelect = async (id: string) => { + const item = await historyManager.getItem(id); + if (item) { + onChatSelected(item); + } + }; + + const handleDelete = async (id: string) => { + await historyManager.deleteItem(id); + setHistory(prevHistory => prevHistory.filter(item => item.id !== id)); + }; + + const groupedHistory = useMemo(() => groupHistory(history), [history]); + + const { t } = useTranslation(); + + return ( + onClose()} + onDismissed={() => { + setHistory([]); + setHasMoreHistory(true); + historyManager.resetContinuationToken(); + }} + > +
+ {Object.entries(groupedHistory).map(([group, items]) => ( +
+

{t(group)}

+ {items.map(item => ( + + ))} +
+ ))} + {history.length === 0 &&

{t("history.noHistory")}

} + {hasMoreHistory && !isLoading && } +
+
+ ); +}; + +function groupHistory(history: HistoryData[]) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(lastWeek.getDate() - 7); + const lastMonth = new Date(today); + lastMonth.setDate(lastMonth.getDate() - 30); + + return history.reduce( + (groups, item) => { + const itemDate = new Date(item.timestamp); + let group; + + if (itemDate >= today) { + group = "history.today"; + } else if (itemDate >= yesterday) { + group = "history.yesterday"; + } else if (itemDate >= lastWeek) { + group = "history.last7days"; + } else if (itemDate >= lastMonth) { + group = "history.last30days"; + } else { + group = itemDate.toLocaleDateString(undefined, { year: "numeric", month: "long" }); + } + + if (!groups[group]) { + groups[group] = []; + } + groups[group].push(item); + return groups; + }, + {} as Record + ); +} + +const InfiniteLoadingButton = ({ func }: { func: () => void }) => { + const buttonRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + if (buttonRef.current) { + func(); + } + } + }); + }, + { + root: null, + threshold: 0 + } + ); + + if (buttonRef.current) { + observer.observe(buttonRef.current); + } + + return () => { + if (buttonRef.current) { + observer.unobserve(buttonRef.current); + } + }; + }, []); + + return