This commit is contained in:
GH Action - Upstream Sync
2024-10-31 01:25:45 +00:00
11 changed files with 291 additions and 42 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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 && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="animate-spin cursor-pointer bg-transparent"
alt="Loading..."
/>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div>
<div className=" my-auto mx-4 mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
</div>
<div className="conversations-container">
{conversations?.map((conversation) => (
{conversations.data?.map((conversation) => (
<ConversationTile
key={conversation.id}
conversation={conversation}

View File

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 3.66667V1M12.7778 5.22222L14.6889 3.31111M14.3333 9H17M12.7778 12.7778L14.6889 14.6889M9 14.3333V17M5.22222 12.7778L3.31111 14.6889M3.66667 9H1M5.22222 5.22222L3.31111 3.31111" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 21 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 9.59652V6.40372C1 5.94773 1.18114 5.51041 1.50358 5.18797C1.82602 4.86553 2.26334 4.68439 2.71933 4.68439H5.21237C5.38045 4.68435 5.54483 4.63503 5.68518 4.54254L10.8432 1.1417C10.9728 1.05636 11.1231 1.00768 11.2781 1.00084C11.4331 0.993993 11.5871 1.02924 11.7237 1.10283C11.8603 1.17643 11.9745 1.28563 12.054 1.41885C12.1336 1.55207 12.1756 1.70435 12.1757 1.85952V14.1407C12.1756 14.2959 12.1336 14.4482 12.054 14.5814C11.9745 14.7146 11.8603 14.8238 11.7237 14.8974C11.5871 14.971 11.4331 15.0063 11.2781 14.9994C11.1231 14.9926 10.9728 14.9439 10.8432 14.8585L5.68518 11.4577C5.54483 11.3652 5.38045 11.3159 5.21237 11.3159H2.71933C2.26334 11.3159 1.82602 11.1347 1.50358 10.8123C1.18114 10.4898 1 10.0525 1 9.59652Z" stroke="#949494" stroke-width="1.4"/>
<path d="M15.1846 4.13149C15.1846 4.13149 16.4741 5.42099 16.4741 7.57016C16.4741 9.71932 15.1846 11.0088 15.1846 11.0088M17.7636 1.55249C17.7636 1.55249 19.9127 3.70166 19.9127 7.57016C19.9127 11.4387 17.7636 13.5878 17.7636 13.5878" stroke="#949494" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.14286C9.35622 1.14286 10.682 1.54502 11.8096 2.29849C12.9373 3.05197 13.8162 4.1229 14.3352 5.37588C14.8542 6.62886 14.99 8.0076 14.7254 9.33776C14.4608 10.6679 13.8077 11.8897 12.8487 12.8487C11.8897 13.8077 10.6679 14.4608 9.33776 14.7254C8.00761 14.99 6.62887 14.8542 5.37589 14.3352C4.12291 13.8162 3.05197 12.9373 2.2985 11.8096C1.54502 10.682 1.14286 9.35621 1.14286 8C1.14286 6.18137 1.86531 4.43723 3.15127 3.15127C4.43723 1.8653 6.18137 1.14286 8 1.14286ZM8 0C6.41775 0 4.87103 0.469192 3.55544 1.34824C2.23985 2.22729 1.21447 3.47672 0.608967 4.93853C0.00346626 6.40034 -0.15496 8.00887 0.153721 9.56072C0.462403 11.1126 1.22433 12.538 2.34315 13.6568C3.46197 14.7757 4.88743 15.5376 6.43928 15.8463C7.99113 16.155 9.59966 15.9965 11.0615 15.391C12.5233 14.7855 13.7727 13.7601 14.6518 12.4446C15.5308 11.129 16 9.58225 16 8C16 5.87827 15.1571 3.84344 13.6569 2.34314C12.1566 0.842854 10.1217 0 8 0Z" fill="#949494"/>
<path d="M10.2857 5.71439V10.2858H5.71427V5.71439H10.2857ZM10.2857 4.57153H5.71427C5.41116 4.57153 5.12047 4.69194 4.90615 4.90627C4.69182 5.1206 4.57141 5.41129 4.57141 5.71439V10.2858C4.57141 10.5889 4.69182 10.8796 4.90615 11.0939C5.12047 11.3083 5.41116 11.4287 5.71427 11.4287H10.2857C10.5888 11.4287 10.8795 11.3083 11.0938 11.0939C11.3081 10.8796 11.4286 10.5889 11.4286 10.2858V5.71439C11.4286 5.41129 11.3081 5.1206 11.0938 4.90627C10.8795 4.69194 10.5888 4.57153 10.2857 4.57153Z" fill="#949494"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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<HTMLAudioElement | null>(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 (
<div
className={`flex items-center justify-center rounded-full p-2 ${
isSpeakHovered
? `bg-[#EEEEEE] dark:bg-purple-taupe`
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
}`}
>
{isLoading ? (
<LoadingIcon className="animate-spin" />
) : isSpeaking ? (
<Stopspeech
className="cursor-pointer fill-none"
onClick={handleSpeakClick}
onMouseEnter={() => setIsSpeakHovered(true)}
onMouseLeave={() => setIsSpeakHovered(false)}
/>
) : (
<Speaker
className="cursor-pointer fill-none"
onClick={handleSpeakClick}
onMouseEnter={() => setIsSpeakHovered(true)}
onMouseLeave={() => setIsSpeakHovered(false)}
/>
)}
</div>
);
}

View File

@@ -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<
<CopyButton text={message} />
</div>
</div>
<div
className={`relative mr-5 block items-center justify-center lg:invisible
${type !== 'ERROR' ? 'group-hover:lg:visible' : 'hidden'}`}
>
<div>
<SpeakButton text={message} /> {/* Add SpeakButton here */}
</div>
</div>
{type === 'ERROR' && (
<div className="relative mr-5 block items-center justify-center">
<div>{retryBtn}</div>

View File

@@ -21,9 +21,10 @@ export async function getDocs(): Promise<Doc[] | null> {
}
}
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 };
}
}

View File

@@ -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',
};

View File

@@ -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',