diff --git a/backend/.env.example b/backend/.env.example index 2cffd6649..f237f99b4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,4 +9,4 @@ DJANGO_SECRET_KEY=... OPENAI_API_TYPE=... OPENAI_API_BASE=... OPENAI_API_VERSION=... -OPENAI_API_KEY=... +OPENAI_API_KEY=... \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 068100805..78c7a8b1a 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -19,7 +19,7 @@ def csrf_token(request): return JsonResponse({"data": token}) -@api_view(["POST"]) +@api_view(["POST","GET"]) def login_view(request): email = request.data.get("email") password = request.data.get("password") diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 9de4f024a..e6499df41 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -20,28 +20,38 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +dotenv_path=os.path.join(BASE_DIR,".env") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] -FRONTEND_URL = os.environ["FRONTEND_URL"] +FRONTEND_URL = "http://localhost:3000" + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4") + +# SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "_*htzwer^io(v06$y(t*9lk-oj-zyzi4)o^o$$=*76+2pbi#+i") + +# FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] +LOGIN_URL = "/auth/login/" # Application definition INSTALLED_APPS = [ + "corsheaders", + 'django_crontab', "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "corsheaders", "rest_framework", "nested_admin", "authentication", @@ -50,6 +60,7 @@ ] MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -57,7 +68,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", ] ROOT_URLCONF = "backend.urls" @@ -146,6 +156,26 @@ FRONTEND_URL, ] -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True -CSRF_COOKIE_SAMESITE = "None" +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False +CSRF_COOKIE_HTTPONLY = False +CSRF_COOKIE_SAMESITE = "Lax" + +CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] +CORS_ALLOW_HEADERS = [ + "authorization", + "content-type", + "x-csrftoken", + "x-requested-with", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], +} + +CRONJOBS = [ + ('0 0 * * *', 'django.core.management.call_command', ['cleanup_old_conversations']), +] diff --git a/backend/backend/urls.py b/backend/backend/urls.py index fa154c7fb..8b5289c37 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -16,5 +16,6 @@ def root_view(request): path("chat/", include("chat.urls")), path("gpt/", include("gpt.urls")), path("auth/", include("authentication.urls")), + path("message/", include("chat.urls")), path("", root_view), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/chat/admin.py b/backend/chat/admin.py index a4e7d15fc..40726d465 100644 --- a/backend/chat/admin.py +++ b/backend/chat/admin.py @@ -2,7 +2,7 @@ from django.utils import timezone from nested_admin.nested import NestedModelAdmin, NestedStackedInline, NestedTabularInline -from chat.models import Conversation, Message, Role, Version +from chat.models import Conversation, Message, Role, Version, History class RoleAdmin(NestedModelAdmin): @@ -85,8 +85,13 @@ class VersionAdmin(NestedModelAdmin): inlines = [MessageInline] list_display = ("id", "conversation", "parent_version", "root_message") +class HistoryAdmin(admin.ModelAdmin): + list_display = ("id", "conversation_id", "version_id", "role", "timestamp") + search_fields = ("role", "content") + list_filter = ("role", "timestamp") admin.site.register(Role, RoleAdmin) admin.site.register(Message, MessageAdmin) admin.site.register(Conversation, ConversationAdmin) admin.site.register(Version, VersionAdmin) +admin.site.register(History, HistoryAdmin) diff --git a/backend/chat/management/commands/cleanup_old_conversations.py b/backend/chat/management/commands/cleanup_old_conversations.py new file mode 100644 index 000000000..17b3d700e --- /dev/null +++ b/backend/chat/management/commands/cleanup_old_conversations.py @@ -0,0 +1,19 @@ +import datetime +from django.core.management.base import BaseCommand +from django.utils import timezone +from chat.models import History + +class Command(BaseCommand): + + #Deletes conversations older than 30 days from SQLite. + + def handle(self, *args, **options): + threshold = timezone.now() - datetime.timedelta(days=30) + + old_conversations = History.objects.filter(timestamp=threshold) + count = old_conversations.count() + + # Delete the old conversations + old_conversations.delete() + + self.stdout.write(self.style.SUCCESS(f"Deleted {count} conversations older than 30 days.")) diff --git a/backend/chat/migrations/0002_history.py b/backend/chat/migrations/0002_history.py new file mode 100644 index 000000000..b1c2e7995 --- /dev/null +++ b/backend/chat/migrations/0002_history.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.7 on 2025-03-13 06:14 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="History", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("conversation_id", models.UUIDField()), + ("version_id", models.UUIDField()), + ("role", models.CharField(max_length=20)), + ("content", models.TextField()), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/backend/chat/migrations/0003_history_question.py b/backend/chat/migrations/0003_history_question.py new file mode 100644 index 000000000..63110621b --- /dev/null +++ b/backend/chat/migrations/0003_history_question.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-03-13 06:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0002_history"), + ] + + operations = [ + migrations.AddField( + model_name="history", + name="question", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/chat/migrations/0004_history_summary.py b/backend/chat/migrations/0004_history_summary.py new file mode 100644 index 000000000..86356311b --- /dev/null +++ b/backend/chat/migrations/0004_history_summary.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-03-13 08:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("chat", "0003_history_question"), + ] + + operations = [ + migrations.AddField( + model_name="history", + name="summary", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/chat/models.py b/backend/chat/models.py index 242788f14..1ef8249c1 100644 --- a/backend/chat/models.py +++ b/backend/chat/models.py @@ -63,3 +63,17 @@ def save(self, *args, **kwargs): def __str__(self): return f"{self.role}: {self.content[:20]}..." + + +class History(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation_id = models.UUIDField() + version_id = models.UUIDField() + role = models.CharField(max_length=20) + question=models.TextField(null=True, blank=True) + content = models.TextField() + summary = models.TextField(blank=True, null=True) + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.role}: {self.content[:50]}..." \ No newline at end of file diff --git a/backend/chat/urls.py b/backend/chat/urls.py index bd8ceadc0..c6e94e9df 100644 --- a/backend/chat/urls.py +++ b/backend/chat/urls.py @@ -19,4 +19,5 @@ ), path("conversations//delete/", views.conversation_soft_delete, name="conversation_delete"), path("versions//add_message/", views.version_add_message, name="version_add_message"), + path("save_message/", views.save_message, name="save_message") ] diff --git a/backend/chat/views.py b/backend/chat/views.py index 0d18f7a69..32910561f 100644 --- a/backend/chat/views.py +++ b/backend/chat/views.py @@ -3,11 +3,16 @@ from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response - -from chat.models import Conversation, Message, Version +from django.http import JsonResponse +import uuid +from chat.models import Conversation, Message, Version, History from chat.serializers import ConversationSerializer, MessageSerializer, TitleSerializer, VersionSerializer from chat.utils.branching import make_branched_conversation +import openai +import os + +openai.api_key = os.getenv("OPENAI_API_KEY") @api_view(["GET"]) def chat_root_view(request): @@ -230,3 +235,39 @@ def version_add_message(request, pk): status=status.HTTP_201_CREATED, ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(["POST"]) +def save_message(request): + conversation_id = request.POST.get("conversation_id") + version_id = request.POST.get("version_id") + role = request.POST.get("role") + content = request.POST.get("content") + question=request.POST.get("question") + + message = History.objects.create( + conversation_id=uuid.UUID(conversation_id), + version_id=uuid.UUID(version_id), + role=role, + question=question, + content=content + ) + + summary = generate_summary(conversation_id) + History.objects.filter(conversation_id=conversation_id).update(summary=summary) + + return JsonResponse({"message": "Message saved", "id": str(message.id)}) + +def generate_summary(conversation_id): + messages = History.objects.filter(conversation_id=conversation_id).order_by("timestamp") + conversation_text = "\n".join([msg.content for msg in messages]) + + response = openai.chat.completions.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "Summarize the following conversation in a concise manner."}, + {"role": "user", "content": conversation_text} + ] + ) + + summary = response.choices[0].message.content.strip() + return summary \ No newline at end of file diff --git a/backend/gpt/urls.py b/backend/gpt/urls.py index f4a0f6045..55ba1f7f4 100644 --- a/backend/gpt/urls.py +++ b/backend/gpt/urls.py @@ -1,5 +1,4 @@ from django.urls import path - from gpt import views urlpatterns = [ @@ -7,4 +6,5 @@ path("title/", views.get_title), path("question/", views.get_answer), path("conversation/", views.get_conversation), + path("chatgpt/", views.chat_with_gpt, name="chat_with_gpt"), ] diff --git a/backend/gpt/views.py b/backend/gpt/views.py index e9c81cb2e..a5ac79f6b 100644 --- a/backend/gpt/views.py +++ b/backend/gpt/views.py @@ -1,10 +1,30 @@ from django.contrib.auth.decorators import login_required from django.http import JsonResponse, StreamingHttpResponse from rest_framework.decorators import api_view - +import openai +import os from src.utils.gpt import get_conversation_answer, get_gpt_title, get_simple_answer +from rest_framework.response import Response +client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"),base_url=os.getenv("OPENAI_BASE")) +@api_view(["POST"]) +def chat_with_gpt(request): + prompt = request.data.get("prompt", "Hello, ChatGPT!") # Default prompt + + try: + response = client.chat.completions.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt} + ], + max_tokens=100 + ) + return Response({"response": response.choices[0].message.content}) + except Exception as e: + return Response({"error": str(e)}, status=500) + @api_view(["GET"]) def gpt_root_view(request): return JsonResponse({"message": "GPT endpoint works!"}) @@ -25,7 +45,7 @@ def get_answer(request): return StreamingHttpResponse(get_simple_answer(data["user_question"], stream=True), content_type="text/html") -@login_required +#@login_required @api_view(["POST"]) def get_conversation(request): data = request.data diff --git a/backend/src/libs/__init__.py b/backend/src/libs/__init__.py index 214cf63db..6ec1f675a 100644 --- a/backend/src/libs/__init__.py +++ b/backend/src/libs/__init__.py @@ -6,7 +6,5 @@ __all__ = ["openai"] load_dotenv() -openai.api_type = os.getenv("OPENAI_API_TYPE") -openai.api_base = os.getenv("OPENAI_API_BASE") -openai.api_version = os.getenv("OPENAI_API_VERSION") -openai.api_key = os.getenv("OPENAI_API_KEY") +# TODO: The 'openai.api_base' option isn't read in the client API. You will need to pass it when you instantiate the client, e.g. 'OpenAI(base_url=os.getenv("OPENAI_API_BASE"))' +# openai.api_base = os.getenv("OPENAI_API_BASE") diff --git a/backend/src/utils/gpt.py b/backend/src/utils/gpt.py index f8a4aa023..fc60f5653 100644 --- a/backend/src/utils/gpt.py +++ b/backend/src/utils/gpt.py @@ -1,6 +1,9 @@ from dataclasses import dataclass from src.libs import openai +import os +import requests +import uuid GPT_40_PARAMS = dict( temperature=0.7, @@ -61,17 +64,37 @@ def get_gpt_title(prompt: str, response: str): def get_conversation_answer(conversation: list[dict[str, str]], model: str, stream: bool = True): + client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE")) + kwargs = {**GPT_40_PARAMS, **dict(stream=stream)} - engine = GPT_VERSIONS[model].engine - for resp in openai.ChatCompletion.create( - engine=engine, + response = client.chat.completions.create( + model=model, messages=[{"role": "system", "content": "You are a helpful assistant."}, *conversation], - **kwargs, - ): - choices = resp.get("choices", []) + **kwargs + ) + + + conversation_id = str(uuid.uuid4()) + version_id = str(uuid.uuid4()) + + assistant_response = "" + + for resp in response: + choices = resp.choices if not choices: continue - chunk = choices.pop()["delta"].get("content") + chunk = choices[0].delta.content if chunk: - yield chunk + assistant_response += chunk # Store response + yield chunk # Stream the chunk + + # Post Assistant Message to API after completion + assistant_message_data = { + "conversation_id": conversation_id, + "version_id": version_id, + "role": "assistant", + "question":conversation[-1]["content"], + "content": assistant_response, + } + requests.post(f"{os.getenv('BACKEND_URL')}/message/save_message/", data=assistant_message_data) diff --git a/frontend/api/auth.js b/frontend/api/auth.js index 5b9a6852d..1633e4b5f 100644 --- a/frontend/api/auth.js +++ b/frontend/api/auth.js @@ -29,10 +29,7 @@ export const getCsrfToken = async () => { export const postLogin = async ({email, password}) => { try { const response = await axiosInstance.post(`/auth/login/`, - { - email, - password - }); + {email,password},{ withCredentials: true }); if (response.status === 200) { return { diff --git a/frontend/api/axios.js b/frontend/api/axios.js index c4882984a..16cf71ec6 100644 --- a/frontend/api/axios.js +++ b/frontend/api/axios.js @@ -3,10 +3,13 @@ import {store} from "../redux/store"; import {backendApiBaseUrl} from "../config"; export const axiosInstance = axios.create({ - baseURL: `${backendApiBaseUrl}`, + baseURL: `http://127.0.0.1:8000`, withCredentials: true, + headers: { "Content-Type": "application/json" }, }); +//console.log({backendApiBaseUrl}) + axiosInstance.interceptors.request.use((config) => { config.headers['Content-Type'] = 'application/json'; config.headers['X-CSRFToken'] = store.getState().auth.csrfToken; diff --git a/frontend/assets/SVGIcon.js b/frontend/assets/SVGIcon.js index bd6b33347..427033c3b 100644 --- a/frontend/assets/SVGIcon.js +++ b/frontend/assets/SVGIcon.js @@ -98,6 +98,12 @@ export const EditMessageIcon = () => { ) } +export const CopyIcon = () => { + const d = "M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z"; + return ( + + ) +} export const LockKeyIcon = () => { const d = "M 104,44 H 90 V 34 A 26,26 0 0 0 38,34 V 44 H 24 A 10,10 0 0 0 14,54 v 56 a 10,10 0 0 0 10,10 h 80 a 10,10 0 0 0 10,-10 V 54 A 10,10 0 0 0 104,44 Z M 50,34 a 14,14 0 0 1 28,0 V 44 H 50 Z m 52,74 H 26 V 56 h 76 z M 64,62 A 16,16 0 0 0 58,92.83 V 96 a 6,6 0 0 0 12,0 V 92.83 A 16,16 0 0 0 64,62 Z m 0,12 a 4,4 0 1 1 -4,4 4,4 0 0 1 4,-4 z"; return ( diff --git a/frontend/components/auth/LoginForm.js b/frontend/components/auth/LoginForm.js index f8808462c..99a4c8e71 100644 --- a/frontend/components/auth/LoginForm.js +++ b/frontend/components/auth/LoginForm.js @@ -19,6 +19,8 @@ function Login({isSubmitting, setIsSubmitting}) { const response = await dispatch(postLoginThunk({email, password})); const result = response.payload; + // console.log(result) + if (result.ok) { router.push('/').catch((error) => { console.error('An unexpected error occurred while redirecting to main page'); diff --git a/frontend/components/chat/Message.js b/frontend/components/chat/Message.js index e9ecb0b14..9d47b2b8d 100644 --- a/frontend/components/chat/Message.js +++ b/frontend/components/chat/Message.js @@ -7,16 +7,13 @@ import {switchConversationVersionThunk} from "../../redux/conversations"; import {setConversation} from "../../redux/currentConversation"; import {AdditionalInfo} from "./MessageAdditionalInfo"; + const parseInlineCode = (text) => { - return text.split("`").map((part, index) => { - if (index % 2 === 1) { - // This part is within backticks - return {part}; - } - return part; - }); + if (typeof text !== "string") return JSON.stringify(text, null, 2); // Ensure text is a string + return text.split("`").map((part, index) => index % 2 === 1 ? {part} : part); }; + const Message = ({message, regenerateUserResponse}) => { const isUser = message.role === 'user'; const classRole = isUser ? styles.user : styles.assistant; @@ -30,18 +27,22 @@ const Message = ({message, regenerateUserResponse}) => { const [editing, setEditing] = useState(false); const [editedMessage, setEditedMessage] = useState(''); const [numRows, setNumRows] = useState(1); + const [copied, setCopied] = useState(false); useEffect(() => { setNumRows(editedMessage.split('\n').length); }, [editedMessage]); let parts; - try { - parts = isUser ? [message.content] : message.content.split('```'); - } catch (e) { - console.error(e); - return null; - } + try { + // Ensure message.content is always treated as a string + const content = typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2); + parts = isUser ? [content] : content.split('```'); + } catch (e) { + console.error("Error processing message.content:", e); + return null; + } + const switchVersion = useCallback((currVersionIndex, where) => { let newVersionId; @@ -61,6 +62,40 @@ const Message = ({message, regenerateUserResponse}) => { }, [versions]); + const handleCopy = () => { + const contentToCopy = typeof message.content === "string" + ? message.content + : JSON.stringify(message.content, null, 2); + + navigator.clipboard.writeText(contentToCopy) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }) + .catch(err => console.error("Copy failed", err)); + }; + + const handleDownload = (code) => { + // Ensure the code starts with triple backticks and remove the first line + const codeLines = code.split('\n'); + if (codeLines[0].startsWith('```')) { + codeLines.shift(); // Remove the first line containing ```language + } + const cleanCode = codeLines.join('\n'); // Join the remaining code + + // Create a downloadable text file + const blob = new Blob([cleanCode], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `code-snippet.txt`; // Save as .txt file + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const renderAdditionalInfo = () => { const editProps = { editing, @@ -82,6 +117,7 @@ const Message = ({message, regenerateUserResponse}) => { return (
+ {editing ? (