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 (
+
+ } disabled={disabled} onClick={onClick}>
+ {t("history.openChatHistory")}
+
+
+ );
+};
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 ;
+};
diff --git a/app/frontend/src/components/HistoryPanel/index.tsx b/app/frontend/src/components/HistoryPanel/index.tsx
new file mode 100644
index 0000000000..e36ac59fe5
--- /dev/null
+++ b/app/frontend/src/components/HistoryPanel/index.tsx
@@ -0,0 +1 @@
+export * from "./HistoryPanel";
diff --git a/app/frontend/src/components/HistoryProviders/HistoryManager.ts b/app/frontend/src/components/HistoryProviders/HistoryManager.ts
new file mode 100644
index 0000000000..fa31c6314d
--- /dev/null
+++ b/app/frontend/src/components/HistoryProviders/HistoryManager.ts
@@ -0,0 +1,18 @@
+import { useMemo } from "react";
+import { IHistoryProvider, HistoryProviderOptions } from "../HistoryProviders/IProvider";
+import { NoneProvider } from "../HistoryProviders/None";
+import { IndexedDBProvider } from "../HistoryProviders/IndexedDB";
+
+export const useHistoryManager = (provider: HistoryProviderOptions): IHistoryProvider => {
+ const providerInstance = useMemo(() => {
+ switch (provider) {
+ case HistoryProviderOptions.IndexedDB:
+ return new IndexedDBProvider("chat-database", "chat-history");
+ case HistoryProviderOptions.None:
+ default:
+ return new NoneProvider();
+ }
+ }, [provider]);
+
+ return providerInstance;
+};
diff --git a/app/frontend/src/components/HistoryProviders/IProvider.ts b/app/frontend/src/components/HistoryProviders/IProvider.ts
new file mode 100644
index 0000000000..330437a8ec
--- /dev/null
+++ b/app/frontend/src/components/HistoryProviders/IProvider.ts
@@ -0,0 +1,18 @@
+import { ChatAppResponse } from "../../api";
+
+export type HistoryMetaData = { id: string; title: string; timestamp: number };
+export type Answers = [user: string, response: ChatAppResponse][];
+
+export const enum HistoryProviderOptions {
+ None = "none",
+ IndexedDB = "indexedDB"
+}
+
+export interface IHistoryProvider {
+ getProviderName(): HistoryProviderOptions;
+ resetContinuationToken(): void;
+ getNextItems(count: number): Promise;
+ addItem(id: string, answers: Answers): Promise;
+ getItem(id: string): Promise;
+ deleteItem(id: string): Promise;
+}
diff --git a/app/frontend/src/components/HistoryProviders/IndexedDB.ts b/app/frontend/src/components/HistoryProviders/IndexedDB.ts
new file mode 100644
index 0000000000..ca8fa19fe8
--- /dev/null
+++ b/app/frontend/src/components/HistoryProviders/IndexedDB.ts
@@ -0,0 +1,104 @@
+import { IDBPDatabase, openDB } from "idb";
+import { IHistoryProvider, Answers, HistoryProviderOptions, HistoryMetaData } from "./IProvider";
+
+export class IndexedDBProvider implements IHistoryProvider {
+ getProviderName = () => HistoryProviderOptions.IndexedDB;
+
+ private dbName: string;
+ private storeName: string;
+ private dbPromise: Promise | null = null;
+ private cursorKey: IDBValidKey | undefined;
+ private isCusorEnd: boolean = false;
+
+ constructor(dbName: string, storeName: string) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.cursorKey = undefined;
+ this.isCusorEnd = false;
+ }
+
+ private async init() {
+ const storeName = this.storeName;
+ if (!this.dbPromise) {
+ this.dbPromise = openDB(this.dbName, 1, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains(storeName)) {
+ const store = db.createObjectStore(storeName, { keyPath: "id" });
+ store.createIndex("timestamp", "timestamp");
+ }
+ }
+ });
+ }
+ return this.dbPromise;
+ }
+
+ resetContinuationToken() {
+ this.cursorKey = undefined;
+ this.isCusorEnd = false;
+ }
+
+ async getNextItems(count: number): Promise {
+ const db = await this.init();
+ const tx = db.transaction(this.storeName, "readonly");
+ const store = tx.objectStore(this.storeName);
+ const index = store.index("timestamp");
+
+ // return empty array if cursor is already at the end
+ if (this.isCusorEnd) {
+ return [];
+ }
+
+ // set cursor to the last key
+ let cursor = this.cursorKey ? await index.openCursor(IDBKeyRange.upperBound(this.cursorKey), "prev") : await index.openCursor(null, "prev");
+
+ // return empty array means no more history or no data. set isCursorEnd to true and return empty array
+ if (!cursor) {
+ this.isCusorEnd = true;
+ return [];
+ }
+
+ const loadedItems: { id: string; title: string; timestamp: number; answers: Answers }[] = [];
+ for (let i = 0; i < count && cursor; i++) {
+ loadedItems.push(cursor.value);
+ cursor = await cursor.continue();
+ }
+
+ // set isCursorEnd to true if cursor is null
+ if (!cursor) {
+ this.isCusorEnd = true;
+ }
+
+ // update cursorKey
+ this.cursorKey = cursor?.key;
+
+ return loadedItems;
+ }
+
+ async addItem(id: string, answers: Answers): Promise {
+ const timestamp = new Date().getTime();
+ const db = await this.init(); // 自動的に初期化
+ const tx = db.transaction(this.storeName, "readwrite");
+ const current = await tx.objectStore(this.storeName).get(id);
+ if (current) {
+ await tx.objectStore(this.storeName).put({ ...current, id, timestamp, answers });
+ } else {
+ const title = answers[0][0].length > 50 ? answers[0][0].substring(0, 50) + "..." : answers[0][0];
+ await tx.objectStore(this.storeName).add({ id, title, timestamp, answers });
+ }
+ await tx.done;
+ return;
+ }
+
+ async getItem(id: string): Promise {
+ const db = await this.init();
+ const tx = db.transaction(this.storeName, "readonly");
+ const item = await tx.objectStore(this.storeName).get(id);
+ return item ? item.answers : null;
+ }
+
+ async deleteItem(id: string): Promise {
+ const db = await this.init();
+ await db.delete(this.storeName, id);
+ return;
+ }
+}
diff --git a/app/frontend/src/components/HistoryProviders/None.ts b/app/frontend/src/components/HistoryProviders/None.ts
new file mode 100644
index 0000000000..a662d54f72
--- /dev/null
+++ b/app/frontend/src/components/HistoryProviders/None.ts
@@ -0,0 +1,20 @@
+import { IHistoryProvider, Answers, HistoryProviderOptions, HistoryMetaData } from "./IProvider";
+
+export class NoneProvider implements IHistoryProvider {
+ getProviderName = () => HistoryProviderOptions.None;
+ resetContinuationToken(): void {
+ return;
+ }
+ async getNextItems(count: number): Promise {
+ return [];
+ }
+ async addItem(id: string, answers: Answers): Promise {
+ return;
+ }
+ async getItem(id: string): Promise {
+ return null;
+ }
+ async deleteItem(id: string): Promise {
+ return;
+ }
+}
diff --git a/app/frontend/src/components/HistoryProviders/index.ts b/app/frontend/src/components/HistoryProviders/index.ts
new file mode 100644
index 0000000000..a9d1757802
--- /dev/null
+++ b/app/frontend/src/components/HistoryProviders/index.ts
@@ -0,0 +1,4 @@
+export * from "./HistoryManager";
+export * from "./IndexedDB";
+export * from "./IProvider";
+export * from "./None";
diff --git a/app/frontend/src/locales/en/translation.json b/app/frontend/src/locales/en/translation.json
index e9539cd2ce..55b7bd372b 100644
--- a/app/frontend/src/locales/en/translation.json
+++ b/app/frontend/src/locales/en/translation.json
@@ -6,6 +6,19 @@
"login": "Login",
"logout": "Logout",
"clearChat": "Clear chat",
+ "history": {
+ "chatHistory": "Chat history",
+ "openChatHistory": "Open chat history",
+ "noHistory": "No chat history",
+ "deleteModalTitle": "Delete chat history",
+ "deleteModalDescription": "This action cannot be undone. Delete this chat history?",
+ "deleteLabel": "Delete",
+ "cancelLabel": "Cancel",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "last7days": "Last 7 days",
+ "last30days": "Last 30 days"
+ },
"upload": {
"fileLabel": "Upload file:",
"uploadedFilesLabel": "Previously uploaded files:",
diff --git a/app/frontend/src/locales/es/translation.json b/app/frontend/src/locales/es/translation.json
index 20a0294f20..382876c9fa 100644
--- a/app/frontend/src/locales/es/translation.json
+++ b/app/frontend/src/locales/es/translation.json
@@ -6,6 +6,19 @@
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"clearChat": "Borrar chat",
+ "history": {
+ "chatHistory": "Historial de chat",
+ "openChatHistory": "Abrir historial de chat",
+ "noHistory": "No hay historial de chat",
+ "deleteModalTitle": "Eliminar historial de chat",
+ "deleteModalDescription": "Esta acción no se puede deshacer. ¿Eliminar este historial de chat?",
+ "deleteLabel": "Eliminar",
+ "cancelLabel": "Cancelar",
+ "today": "Hoy",
+ "yesterday": "Ayer",
+ "last7days": "Últimos 7 días",
+ "last30days": "Últimos 30 días"
+ },
"upload": {
"fileLabel": "Subir archivo:",
"uploadedFilesLabel": "Archivos subidos previamente:",
diff --git a/app/frontend/src/locales/fr/translation.json b/app/frontend/src/locales/fr/translation.json
index d58812f99e..9bd579e239 100644
--- a/app/frontend/src/locales/fr/translation.json
+++ b/app/frontend/src/locales/fr/translation.json
@@ -6,6 +6,19 @@
"login": "Connexion",
"logout": "Déconnexion",
"clearChat": "Effacer le chat",
+ "history": {
+ "chatHistory": "Historique du chat",
+ "openChatHistory": "Ouvrir l'historique du chat",
+ "noHistory": "Pas d'historique dans ce chat",
+ "deleteModalTitle": "Supprimer l'historique du chat",
+ "deleteModalDescription": "Cette action est irréversible. Supprimer l'historique de ce chat ?",
+ "deleteLabel": "Supprimer",
+ "cancelLabel": "Annuler",
+ "today": "Aujourd'hui",
+ "yesterday": "Hier",
+ "last7days": "7 derniers jours",
+ "last30days": "30 derniers jours"
+ },
"upload": {
"fileLabel": "Télécharger le fichier:",
"uploadedFilesLabel": "Fichiers précédemment téléchargés:",
diff --git a/app/frontend/src/locales/ja/translation.json b/app/frontend/src/locales/ja/translation.json
index 7d0fc61cc5..73a5599f37 100644
--- a/app/frontend/src/locales/ja/translation.json
+++ b/app/frontend/src/locales/ja/translation.json
@@ -6,6 +6,19 @@
"login": "ログイン",
"logout": "ログアウト",
"clearChat": "チャットをクリア",
+ "history": {
+ "chatHistory": "チャット履歴",
+ "openChatHistory": "チャット履歴を開く",
+ "noHistory": "チャット履歴がありません",
+ "deleteModalTitle": "チャット履歴の削除",
+ "deleteModalDescription": "この操作は取り消せません。このチャット履歴を削除しますか?",
+ "deleteLabel": "削除",
+ "cancelLabel": "キャンセル",
+ "today": "今日",
+ "yesterday": "昨日",
+ "last7days": "過去7日間",
+ "last30days": "過去30日間"
+ },
"upload": {
"fileLabel": "ファイルをアップロード:",
"uploadedFilesLabel": "アップロード済みのファイル:",
diff --git a/app/frontend/src/pages/chat/Chat.module.css b/app/frontend/src/pages/chat/Chat.module.css
index 648a7efb5f..d4f4914fab 100644
--- a/app/frontend/src/pages/chat/Chat.module.css
+++ b/app/frontend/src/pages/chat/Chat.module.css
@@ -91,6 +91,11 @@
font-size: 1.75rem;
}
+.commandsSplitContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
.commandsContainer {
display: flex;
padding-left: 1rem;
@@ -119,6 +124,10 @@
padding: 0.75rem 1.5rem 1.5rem;
}
+ .commandsSplitContainer {
+ padding-left: 1rem;
+ }
+
.commandsContainer {
padding-left: 0rem;
padding-right: 0rem;
diff --git a/app/frontend/src/pages/chat/Chat.tsx b/app/frontend/src/pages/chat/Chat.tsx
index 1129e0f590..21f933664a 100644
--- a/app/frontend/src/pages/chat/Chat.tsx
+++ b/app/frontend/src/pages/chat/Chat.tsx
@@ -26,6 +26,9 @@ import { ExampleList } from "../../components/Example";
import { UserChatMessage } from "../../components/UserChatMessage";
import { HelpCallout } from "../../components/HelpCallout";
import { AnalysisPanel, AnalysisPanelTabs } from "../../components/AnalysisPanel";
+import { HistoryPanel } from "../../components/HistoryPanel";
+import { HistoryProviderOptions, useHistoryManager } from "../../components/HistoryProviders";
+import { HistoryButton } from "../../components/HistoryButton";
import { SettingsButton } from "../../components/SettingsButton";
import { ClearChatButton } from "../../components/ClearChatButton";
import { UploadFile } from "../../components/UploadFile";
@@ -39,6 +42,7 @@ import { LanguagePicker } from "../../i18n/LanguagePicker";
const Chat = () => {
const [isConfigPanelOpen, setIsConfigPanelOpen] = useState(false);
+ const [isHistoryPanelOpen, setIsHistoryPanelOpen] = useState(false);
const [promptTemplate, setPromptTemplate] = useState("");
const [temperature, setTemperature] = useState(0.3);
const [seed, setSeed] = useState(null);
@@ -80,6 +84,7 @@ const Chat = () => {
const [showSpeechInput, setShowSpeechInput] = useState(false);
const [showSpeechOutputBrowser, setShowSpeechOutputBrowser] = useState(false);
const [showSpeechOutputAzure, setShowSpeechOutputAzure] = useState(false);
+ const [showChatHistoryBrowser, setShowChatHistoryBrowser] = useState(false);
const audio = useRef(new Audio()).current;
const [isPlaying, setIsPlaying] = useState(false);
@@ -105,6 +110,7 @@ const Chat = () => {
setShowSpeechInput(config.showSpeechInput);
setShowSpeechOutputBrowser(config.showSpeechOutputBrowser);
setShowSpeechOutputAzure(config.showSpeechOutputAzure);
+ setShowChatHistoryBrowser(config.showChatHistoryBrowser);
});
};
@@ -154,6 +160,9 @@ const Chat = () => {
const client = useLogin ? useMsal().instance : undefined;
const { loggedIn } = useContext(LoginContext);
+ const historyProvider: HistoryProviderOptions = showChatHistoryBrowser ? HistoryProviderOptions.IndexedDB : HistoryProviderOptions.None;
+ const historyManager = useHistoryManager(historyProvider);
+
const makeApiRequest = async (question: string) => {
lastQuestionRef.current = question;
@@ -207,12 +216,18 @@ const Chat = () => {
if (shouldStream) {
const parsedResponse: ChatAppResponse = await handleAsyncRequest(question, answers, response.body);
setAnswers([...answers, [question, parsedResponse]]);
+ if (typeof parsedResponse.session_state === "string" && parsedResponse.session_state !== "") {
+ historyManager.addItem(parsedResponse.session_state, [...answers, [question, parsedResponse]]);
+ }
} else {
const parsedResponse: ChatAppResponseOrError = await response.json();
if (parsedResponse.error) {
throw Error(parsedResponse.error);
}
setAnswers([...answers, [question, parsedResponse as ChatAppResponse]]);
+ if (typeof parsedResponse.session_state === "string" && parsedResponse.session_state !== "") {
+ historyManager.addItem(parsedResponse.session_state, [...answers, [question, parsedResponse as ChatAppResponse]]);
+ }
}
setSpeechUrls([...speechUrls, null]);
} catch (e) {
@@ -352,12 +367,17 @@ const Chat = () => {
{t("pageTitle")}
-
-
- {showUserUpload &&
}
-
setIsConfigPanelOpen(!isConfigPanelOpen)} />
+
+
+ {showChatHistoryBrowser && setIsHistoryPanelOpen(!isHistoryPanelOpen)} />}
+
+
+
+ {showUserUpload && }
+ setIsConfigPanelOpen(!isConfigPanelOpen)} />
+
-
+
{!lastQuestionRef.current ? (
@@ -458,6 +478,20 @@ const Chat = () => {
/>
)}
+ {showChatHistoryBrowser && (
+
setIsHistoryPanelOpen(false)}
+ onChatSelected={answers => {
+ if (answers.length === 0) return;
+ setAnswers(answers);
+ lastQuestionRef.current = answers[answers.length - 1][0];
+ }}
+ />
+ )}
+