Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@ import { Button } from "@/components/ui/button";
import { GroundingFiles } from "@/components/ui/grounding-files";
import GroundingFileView from "@/components/ui/grounding-file-view";
import StatusMessage from "@/components/ui/status-message";
import HistoryPanel from "@/components/ui/history-panel";

import useRealTime from "@/hooks/useRealtime";
import useAudioRecorder from "@/hooks/useAudioRecorder";
import useAudioPlayer from "@/hooks/useAudioPlayer";

import { GroundingFile, ToolResult } from "./types";
import { GroundingFile, HistoryItem, ToolResult } from "./types";

import logo from "./assets/logo.svg";

function App() {
const [isRecording, setIsRecording] = useState(false);
const [groundingFiles, setGroundingFiles] = useState<GroundingFile[]>([]);
const [selectedFile, setSelectedFile] = useState<GroundingFile | null>(null);
const [assistantGroundingFiles, setAssistantGroundingFiles] = useState<GroundingFile[]>([]); // New state for assistant grounding files
const [showHistory, setShowHistory] = useState(false);
const [history, setHistory] = useState<HistoryItem[]>([]);

const { startSession, addUserAudio, inputAudioBufferClear } = useRealTime({
enableInputAudioTranscription: true, // Enable input audio transcription from the user to show in the history
onWebSocketOpen: () => console.log("WebSocket connection opened"),
onWebSocketClose: () => console.log("WebSocket connection closed"),
onWebSocketError: event => console.error("WebSocket error:", event),
Expand All @@ -33,12 +38,40 @@ function App() {
},
onReceivedExtensionMiddleTierToolResponse: message => {
const result: ToolResult = JSON.parse(message.tool_result);

const files: GroundingFile[] = result.sources.map(x => {
return { id: x.chunk_id, name: x.title, content: x.chunk };
});

setGroundingFiles(prev => [...prev, ...files]);
setGroundingFiles(prev => [...prev, ...files]); // Keep track of all files used in the session
setAssistantGroundingFiles(files); // Store the grounding files for the assistant
},
onReceivedInputAudioTranscriptionCompleted: message => {
// Update history with input audio transcription when completed
const newHistoryItem: HistoryItem = {
id: message.event_id,
transcript: message.transcript,
groundingFiles: [], // Assuming no grounding files are associated with the transcription completed
sender: "user", // Indicate that this message is from the user
timestamp: new Date() // Add timestamp
};
setHistory(prev => [...prev, newHistoryItem]);
},
onReceivedResponseDone: message => {
const transcript = message.response.output.map(output => output.content?.map(content => content.transcript).join(" ")).join(" ");
if (!transcript) {
return; // Skip adding the history item if the transcript is null or empty
}

// Update history with response done
const newHistoryItem: HistoryItem = {
id: message.event_id,
transcript: transcript,
groundingFiles: assistantGroundingFiles, // Include the assistant's grounding files
sender: "assistant", // Indicate that this message is from the assistant
timestamp: new Date() // Add timestamp
};
setHistory(prev => [...prev, newHistoryItem]);
setAssistantGroundingFiles([]); // Clear the assistant grounding files after use
}
});

Expand Down Expand Up @@ -92,13 +125,20 @@ function App() {
<StatusMessage isRecording={isRecording} />
</div>
<GroundingFiles files={groundingFiles} onSelected={setSelectedFile} />
<div className="mb-4 flex space-x-4">
<Button onClick={() => setShowHistory(!showHistory)} className="h-12 w-60 bg-blue-500 hover:bg-blue-600" aria-label={t("app.showHistory")}>
{t("app.showHistory")}
</Button>
</div>
</main>

<footer className="py-4 text-center">
<p>{t("app.footer")}</p>
</footer>

<GroundingFileView groundingFile={selectedFile} onClosed={() => setSelectedFile(null)} />

<HistoryPanel show={showHistory} history={history} onClosed={() => setShowHistory(false)} onSelectedGroundingFile={setSelectedFile} />
</div>
);
}
Expand Down
80 changes: 61 additions & 19 deletions app/frontend/src/components/ui/history-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";

Expand All @@ -17,6 +18,30 @@ type Properties = {

export default function HistoryPanel({ show, history, onClosed, onSelectedGroundingFile }: Properties) {
const { t } = useTranslation();
const historyEndRef = useRef<HTMLDivElement>(null);

// Scroll to the bottom whenever the history changes
useEffect(() => {
if (historyEndRef.current) {
historyEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [history]);

const formatTimestamp = (timestamp: Date) => {
const hours = timestamp.getHours();
const minutes = timestamp.getMinutes();
const ampm = hours >= 12 ? "PM" : "AM";
const formattedHours = hours % 12 || 12;
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes;
return `${formattedHours}:${formattedMinutes} ${ampm}`;
};

const shouldShowTimestamp = (current: Date, next?: Date, isLast?: boolean) => {
if (isLast) return false; // Do not show timestamp for the last message
if (!next) return true;
const diff = (next.getTime() - current.getTime()) / 1000; // Difference in seconds
return diff > 60; // Show timestamp if more than 30 seconds have passed
};

return (
<AnimatePresence>
Expand All @@ -28,27 +53,44 @@ export default function HistoryPanel({ show, history, onClosed, onSelectedGround
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed inset-y-0 right-0 z-40 w-full overflow-y-auto bg-white shadow-lg sm:w-96"
>
<div className="sticky top-0 z-10 mb-4 flex items-center justify-between bg-white px-4 py-2">
<h2 className="text-xl font-bold">{t("history.answerHistory")}</h2>
<Button variant="ghost" size="sm" onClick={onClosed}>
<X className="h-5 w-5" />
</Button>
</div>
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">{t("history.answerHistory")}</h2>
<Button variant="ghost" size="sm" onClick={onClosed}>
<X className="h-5 w-5" />
</Button>
</div>
{history.length > 0 ? (
history.map((item, index) => (
<div key={index} className="mb-6 border-b border-gray-200 pb-6">
<h3 className="mb-2 font-semibold">{item.id}</h3>
<pre className="mb-2 overflow-x-auto whitespace-pre-wrap rounded-md bg-gray-100 p-3 text-sm">
<code className="block h-24 overflow-y-auto">{item.transcript}</code>
</pre>
<div className="mt-2 flex flex-wrap gap-2">
{item.groundingFiles.map((file, index) => (
<GroundingFile key={index} value={file} onClick={() => onSelectedGroundingFile(file)} />
))}
</div>
</div>
))
<div className="space-y-4">
{history.map((item, index) => {
const nextItem = history[index + 1];
const isLast = index === history.length - 1;
const showTimestamp = shouldShowTimestamp(
new Date(item.timestamp),
nextItem ? new Date(nextItem.timestamp) : undefined,
isLast
);
return (
<div key={index}>
<div
className={`rounded-lg p-4 shadow ${item.sender === "user" ? "ml-auto bg-blue-100 pl-4" : "bg-gray-100"}`}
style={{ maxWidth: "75%" }} // Optional: Limit the width of the bubbles
>
<p className="text-sm text-gray-700">{item.transcript}</p>
<div className="mt-2 flex flex-wrap gap-2">
{item.groundingFiles.map((file, index) => (
<GroundingFile key={index} value={file} onClick={() => onSelectedGroundingFile(file)} />
))}
</div>
</div>
{showTimestamp && (
<div className="mt-2 text-center text-xs text-gray-500">{formatTimestamp(new Date(item.timestamp))}</div>
)}
</div>
);
})}
<div ref={historyEndRef} />
</div>
) : (
<p className="text-gray-500">{t("history.noHistory")}</p>
)}
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"app": {
"title": "Talk to your data",
"footer": "Built with Azure AI Search + Azure OpenAI",
"showHistory": "Show Chat History",
"stopRecording": "Stop recording",
"startRecording": "Start recording",
"stopConversation": "Stop conversation"
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"app": {
"title": "Habla con tus datos",
"footer": "Creado con Azure AI Search + Azure OpenAI",
"showHistory": "Mostrar historial de chat",
"stopRecording": "Detener grabación",
"startRecording": "Comenzar grabación",
"stopConversation": "Detener conversación"
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"app": {
"title": "Parlez à vos données",
"footer": "Créée avec Azure AI Search + Azure OpenAI",
"showHistory": "Afficher l'historique du chat",
"stopRecording": "Arrêter l'enregistrement",
"startRecording": "Commencer l'enregistrement",
"stopConversation": "Arrêter la conversation"
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"app": {
"title": "データと話す",
"footer": "Azure AI Search + Azure OpenAI で構築",
"showHistory": "チャット履歴を表示",
"stopRecording": "録音を停止",
"startRecording": "録音を開始",
"stopConversation": "会話を停止"
Expand Down
21 changes: 19 additions & 2 deletions app/frontend/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,64 @@
// Represents a grounding file
export type GroundingFile = {
id: string;
name: string;
content: string;
};

// Represents an item in the history
export type HistoryItem = {
id: string;
transcript: string;
groundingFiles: GroundingFile[];
sender: "user" | "assistant"; // Add sender field
timestamp: Date; // Add timestamp field
};

// Represents a command to update the session
export type SessionUpdateCommand = {
type: "session.update";
session: {
turn_detection?: {
type: "server_vad" | "none";
threshold?: number;
prefix_padding_ms?: number;
silence_duration_ms?: number;
};
input_audio_transcription?: {
model: "whisper-1";
};
};
};

// Represents a command to append audio to the input buffer
export type InputAudioBufferAppendCommand = {
type: "input_audio_buffer.append";
audio: string;
audio: string; // Ensure this is a valid base64-encoded string
};

// Represents a command to clear the input audio buffer
export type InputAudioBufferClearCommand = {
type: "input_audio_buffer.clear";
};

// Represents a generic message
export type Message = {
type: string;
};

// Represents a response containing an audio delta
export type ResponseAudioDelta = {
type: "response.audio.delta";
delta: string;
delta: string; // Ensure this is a valid base64-encoded string
};

// Represents a response containing an audio transcript delta
export type ResponseAudioTranscriptDelta = {
type: "response.audio_transcript.delta";
delta: string;
};

// Represents a response indicating that input audio transcription is completed
export type ResponseInputAudioTranscriptionCompleted = {
type: "conversation.item.input_audio_transcription.completed";
event_id: string;
Expand All @@ -53,6 +67,7 @@ export type ResponseInputAudioTranscriptionCompleted = {
transcript: string;
};

// Represents a response indicating that the response is done
export type ResponseDone = {
type: "response.done";
event_id: string;
Expand All @@ -62,13 +77,15 @@ export type ResponseDone = {
};
};

// Represents a response from an extension middle tier tool
export type ExtensionMiddleTierToolResponse = {
type: "extension.middle_tier_tool.response";
previous_item_id: string;
tool_name: string;
tool_result: string; // JSON string that needs to be parsed into ToolResult
};

// Represents the result from a tool
export type ToolResult = {
sources: { chunk_id: string; title: string; chunk: string }[];
};