diff --git a/extensions/discord/bot.py b/extensions/discord/bot.py index 3cb1d1e2..94daf7e2 100644 --- a/extensions/discord/bot.py +++ b/extensions/discord/bot.py @@ -1,25 +1,60 @@ import os import re - +import logging +import aiohttp import discord -import requests from discord.ext import commands import dotenv dotenv.load_dotenv() -# Replace 'YOUR_BOT_TOKEN' with your bot's token +# Enable logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Bot configuration TOKEN = os.getenv("DISCORD_TOKEN") -PREFIX = '@DocsGPT' -BASE_API_URL = 'http://localhost:7091' +PREFIX = '!' # Command prefix +BASE_API_URL = os.getenv("API_BASE", "https://gptcloud.arc53.com") +API_URL = BASE_API_URL + "/api/answer" +API_KEY = os.getenv("API_KEY") intents = discord.Intents.default() intents.message_content = True bot = commands.Bot(command_prefix=PREFIX, intents=intents) +# Store conversation history per user +conversation_histories = {} + +def chunk_string(text, max_length=2000): + """Splits a string into chunks of a specified maximum length.""" + # Create list to store the split strings + chunks = [] + # Loop through the text, create substrings with max_length + while len(text) > max_length: + # Find last space within the limit + idx = text.rfind(' ', 0, max_length) + # Ensure we don't have an empty part + if idx == -1: + # If no spaces, just take chunk + chunks.append(text[:max_length]) + text = text[max_length:] + else: + # Push whatever we've got up to the last space + chunks.append(text[:idx]) + text = text[idx+1:] + # Catches the remaining part + chunks.append(text) + return chunks + +def escape_markdown(text): + """Escapes Discord markdown characters.""" + escape_chars = r'\*_$$$$()~>#+-=|{}.!' + return re.sub(f'([{re.escape(escape_chars)}])', r'\\\1', text) def split_string(input_str): + """Splits the input string to detect bot mentions.""" pattern = r'^<@!?{0}>\s*'.format(bot.user.id) match = re.match(pattern, input_str) if match: @@ -27,42 +62,97 @@ def split_string(input_str): return str(bot.user.id), content return None, input_str - @bot.event async def on_ready(): print(f'{bot.user.name} has connected to Discord!') - -async def fetch_answer(question): - data = { - 'sender': 'discord', - 'question': question, - 'history': '' +async def generate_answer(question, messages, conversation_id): + """Generates an answer using the external API.""" + payload = { + "question": question, + "api_key": API_KEY, + "history": messages, + "conversation_id": conversation_id } - headers = {"Content-Type": "application/json", - "Accept": "application/json"} - response = requests.post(BASE_API_URL + '/api/answer', json=data, headers=headers) - if response.status_code == 200: - return response.json()['answer'] - return 'Sorry, I could not fetch the answer.' + headers = { + "Content-Type": "application/json; charset=utf-8" + } + timeout = aiohttp.ClientTimeout(total=60) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(API_URL, json=payload, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + conversation_id = data.get("conversation_id") + answer = data.get("answer", "Sorry, I couldn't find an answer.") + return {"answer": answer, "conversation_id": conversation_id} + else: + return {"answer": "Sorry, I couldn't find an answer.", "conversation_id": None} +@bot.command(name="start") +async def start(ctx): + """Handles the /start command.""" + await ctx.send(f"Hi {ctx.author.mention}! How can I assist you today?") + +@bot.command(name="custom_help") +async def custom_help_command(ctx): + """Handles the /custom_help command.""" + help_text = ( + "Here are the available commands:\n" + "`!start` - Begin a new conversation with the bot\n" + "`!help` - Display this help message\n\n" + "You can also mention me or send a direct message to ask a question!" + ) + await ctx.send(help_text) @bot.event async def on_message(message): if message.author == bot.user: return - content = message.content.strip() - prefix, content = split_string(content) - if prefix is None: - return - - part_prefix = str(bot.user.id) - if part_prefix == prefix: - answer = await fetch_answer(content) - await message.channel.send(answer) - + # Process commands first await bot.process_commands(message) + # Check if the message is in a DM channel + if isinstance(message.channel, discord.DMChannel): + content = message.content.strip() + else: + # In guild channels, check if the message mentions the bot at the start + content = message.content.strip() + prefix, content = split_string(content) + if prefix is None: + return + part_prefix = str(bot.user.id) + if part_prefix != prefix: + return # Bot not mentioned at the start, so do not process -bot.run(TOKEN) + # Now process the message + user_id = message.author.id + if user_id not in conversation_histories: + conversation_histories[user_id] = { + "history": [], + "conversation_id": None + } + + conversation = conversation_histories[user_id] + conversation["history"].append({"prompt": content}) + + # Generate the answer + response_doc = await generate_answer( + content, + conversation["history"], + conversation["conversation_id"] + ) + answer = response_doc["answer"] + conversation_id = response_doc["conversation_id"] + + answer_chunks = chunk_string(answer) + for chunk in answer_chunks: + await message.channel.send(chunk) + + conversation["history"][-1]["response"] = answer + conversation["conversation_id"] = conversation_id + + # Keep conversation history to last 10 exchanges + conversation["history"] = conversation["history"][-10:] + +bot.run(TOKEN) \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4087e4f5..9973bb9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3075,6 +3075,24 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/easy-speech": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/easy-speech/-/easy-speech-2.4.0.tgz", + "integrity": "sha512-wpMv29DEoeP/eyXr4aXpDqd9DvlXl7aQs7BgfKbjGVxqkmQPgNmpbF5YULaTH5bc/5qrteg5MDfCD2Zd0qr4rQ==", + "funding": [ + { + "type": "GitHub", + "url": "https://github.com/sponsors/jankapunkt" + }, + { + "type": "PayPal", + "url": "https://paypal.me/kuesterjan" + } + ], + "engines": { + "node": ">= 14.x" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.11", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.11.tgz", diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index b38ade53..ce23e90e 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -39,6 +39,8 @@ import { setSelectedDocs, setSourceDocs, } from './preferences/preferenceSlice'; +import Spinner from './assets/spinner.svg'; +import SpinnerDark from './assets/spinner-dark.svg'; import { selectQueries } from './conversation/conversationSlice'; import Upload from './upload/Upload'; import Help from './components/Help'; @@ -70,6 +72,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const conversations = useSelector(selectConversations); const modalStateDeleteConv = useSelector(selectModalStateDeleteConv); const conversationId = useSelector(selectConversationId); + const [isDeletingConversation, setIsDeletingConversation] = useState(false); const { isMobile } = useMediaQuery(); const [isDarkTheme] = useDarkTheme(); @@ -91,25 +94,28 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { const navigate = useNavigate(); useEffect(() => { - if (!conversations) { + if (!conversations?.data) { fetchConversations(); } if (queries.length === 0) { resetConversation(); } - }, [conversations, dispatch]); + }, [conversations?.data, dispatch]); async function fetchConversations() { + dispatch(setConversations({ ...conversations, loading: true })); return await getConversations() .then((fetchedConversations) => { dispatch(setConversations(fetchedConversations)); }) .catch((error) => { console.error('Failed to fetch conversations: ', error); + dispatch(setConversations({ data: null, loading: false })); }); } const handleDeleteAllConversations = () => { + setIsDeletingConversation(true); conversationService .deleteAll() .then(() => { @@ -119,6 +125,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }; const handleDeleteConversation = (id: string) => { + setIsDeletingConversation(true); conversationService .delete(id, {}) .then(() => { @@ -205,6 +212,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { setNavOpen(!isMobile); }, [isMobile]); useDefaultDocument(); + return ( <> {!navOpen && ( @@ -306,13 +314,22 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { id="conversationsMainDiv" className="mb-auto h-[78vh] overflow-y-auto overflow-x-hidden dark:text-white" > - {conversations && conversations.length > 0 ? ( + {conversations?.loading && !isDeletingConversation && ( +
+ Loading... +
+ )} + {conversations?.data && conversations.data.length > 0 ? (

{t('chats')}

- {conversations?.map((conversation) => ( + {conversations.data?.map((conversation) => ( + + diff --git a/frontend/src/assets/speaker.svg b/frontend/src/assets/speaker.svg new file mode 100644 index 00000000..6c379177 --- /dev/null +++ b/frontend/src/assets/speaker.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/stopspeech.svg b/frontend/src/assets/stopspeech.svg new file mode 100644 index 00000000..f77a235b --- /dev/null +++ b/frontend/src/assets/stopspeech.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/components/TextToSpeechButton.tsx b/frontend/src/components/TextToSpeechButton.tsx new file mode 100644 index 00000000..2cb9e8f8 --- /dev/null +++ b/frontend/src/components/TextToSpeechButton.tsx @@ -0,0 +1,94 @@ +import { useState, useRef } from 'react'; +import Speaker from '../assets/speaker.svg?react'; +import Stopspeech from '../assets/stopspeech.svg?react'; +import LoadingIcon from '../assets/Loading.svg?react'; // Add a loading icon SVG here +const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; + +export default function SpeakButton({ + text, + colorLight, + colorDark, +}: { + text: string; + colorLight?: string; + colorDark?: string; +}) { + const [isSpeaking, setIsSpeaking] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isSpeakHovered, setIsSpeakHovered] = useState(false); + const audioRef = useRef(null); + + const handleSpeakClick = async () => { + if (isSpeaking) { + // Stop audio if it's currently playing + audioRef.current?.pause(); + audioRef.current = null; + setIsSpeaking(false); + return; + } + + try { + // Set loading state and initiate TTS request + setIsLoading(true); + + const response = await fetch(apiHost + '/api/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + + const data = await response.json(); + + if (data.success && data.audio_base64) { + // Create and play the audio + const audio = new Audio(`data:audio/mp3;base64,${data.audio_base64}`); + audioRef.current = audio; + + audio.play().then(() => { + setIsSpeaking(true); + setIsLoading(false); + + // Reset when audio ends + audio.onended = () => { + setIsSpeaking(false); + audioRef.current = null; + }; + }); + } else { + console.error('Failed to retrieve audio.'); + setIsLoading(false); + } + } catch (error) { + console.error('Error fetching audio from TTS endpoint', error); + setIsLoading(false); + } + }; + + return ( +
+ {isLoading ? ( + + ) : isSpeaking ? ( + setIsSpeakHovered(true)} + onMouseLeave={() => setIsSpeakHovered(false)} + /> + ) : ( + setIsSpeakHovered(true)} + onMouseLeave={() => setIsSpeakHovered(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 2ccf1ca3..a9a05168 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -7,7 +7,6 @@ import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; - import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import Dislike from '../assets/dislike.svg?react'; import Document from '../assets/document.svg'; @@ -23,6 +22,7 @@ import { } from '../preferences/preferenceSlice'; import classes from './ConversationBubble.module.css'; import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; +import SpeakButton from '../components/TextToSpeechButton'; const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; @@ -336,6 +336,14 @@ const ConversationBubble = forwardRef<
+
+
+ {/* Add SpeakButton here */} +
+
{type === 'ERROR' && (
{retryBtn}
diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index a8e46a72..ed730f7c 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -21,9 +21,10 @@ export async function getDocs(): Promise { } } -export async function getConversations(): Promise< - { name: string; id: string }[] | null -> { +export async function getConversations(): Promise<{ + data: { name: string; id: string }[] | null; + loading: boolean; +}> { try { const response = await conversationService.getConversations(); const data = await response.json(); @@ -34,10 +35,10 @@ export async function getConversations(): Promise< conversations.push(conversation as { name: string; id: string }); }); - return conversations; + return { data: conversations, loading: false }; } catch (error) { console.log(error); - return null; + return { data: null, loading: false }; } } diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts index 6fb2480b..c566ba70 100644 --- a/frontend/src/preferences/preferenceSlice.ts +++ b/frontend/src/preferences/preferenceSlice.ts @@ -15,7 +15,10 @@ export interface Preference { token_limit: number; selectedDocs: Doc | null; sourceDocs: Doc[] | null; - conversations: { name: string; id: string }[] | null; + conversations: { + data: { name: string; id: string }[] | null; + loading: boolean; + }; modalState: ActiveState; } @@ -34,7 +37,10 @@ const initialState: Preference = { retriever: 'classic', } as Doc, sourceDocs: null, - conversations: null, + conversations: { + data: null, + loading: false, + }, modalState: 'INACTIVE', }; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 565ea8cc..5843d493 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -23,7 +23,10 @@ const preloadedState: { preference: Preference } = { chunks: JSON.parse(chunks ?? '2').toString(), token_limit: token_limit ? parseInt(token_limit) : 2000, selectedDocs: doc !== null ? JSON.parse(doc) : null, - conversations: null, + conversations: { + data: null, + loading: false, + }, sourceDocs: [ { name: 'default',