From f59fce4429c8bf617f3a12f9c2394a9ed603518b Mon Sep 17 00:00:00 2001 From: Hiroshi Fujita Date: Wed, 25 Sep 2024 00:00:51 +0000 Subject: [PATCH 1/9] 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. --- app/backend/app.py | 4 + app/backend/config.py | 1 + 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/ja/translation.json | 13 ++ app/frontend/src/pages/chat/Chat.module.css | 9 + app/frontend/src/pages/chat/Chat.tsx | 43 ++++- 23 files changed, 638 insertions(+), 6 deletions(-) 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/app/backend/app.py b/app/backend/app.py index 226ef98506..047c25f2f5 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_ENABLED, CONFIG_CHAT_VISION_APPROACH, CONFIG_CREDENTIAL, CONFIG_GPT4V_DEPLOYED, @@ -276,6 +277,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], + "showChatHistory": current_app.config[CONFIG_CHAT_HISTORY_ENABLED], } ) @@ -439,6 +441,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 = os.getenv("USE_CHAT_HISTORY", "").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 +612,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_ENABLED] = USE_CHAT_HISTORY # 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..47e4bf43e1 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_ENABLED = "chat_history_enabled" diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 4dbd119a1d..50ee25718f 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", @@ -3233,6 +3234,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 d3cc7f04d6..9d79c9ade1 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..8c6a301d28 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; + showChatHistory: 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