diff --git a/commonthread/commonthread/settings.py b/commonthread/commonthread/settings.py index 9e7d861..12092bd 100644 --- a/commonthread/commonthread/settings.py +++ b/commonthread/commonthread/settings.py @@ -88,6 +88,8 @@ CORS_ORIGIN_ALLOW_ALL = True # Application definition +PERPLEXITY_API_KEY = "YOUR_PERPLEXITY_API_KEY_HERE" # TODO: Replace with your actual key and manage it securely (e.g., using environment variables or a secrets manager). Do not commit the actual key to the repository. + INSTALLED_APPS = [ "ct_application.apps.CtApplicationConfig", "django.contrib.admin", diff --git a/commonthread/commonthread/urls.py b/commonthread/commonthread/urls.py index eeb34b3..4c500b2 100644 --- a/commonthread/commonthread/urls.py +++ b/commonthread/commonthread/urls.py @@ -44,7 +44,10 @@ delete_story, get_stories, get_story, -) + feat/project-chat-perplexity + project_chat_api, + story_chat_api # Added new story_chat_api view + urlpatterns = [ path("", home_test, name="home"), # GET / @@ -82,4 +85,10 @@ path("story//delete", delete_story, name="story-delete"), path("stories/", get_stories, name="get_stories"), path("story/", get_story, name="get_story"), + + # New Story Chat API endpoint + path("story//chat", story_chat_api, name="story-chat-api"), + + # Perplexity Chat API endpoint for projects + path("project//chat", project_chat_api, name="project-chat-api"), ] diff --git a/commonthread/ct_application/ml/perplexity_service.py b/commonthread/ct_application/ml/perplexity_service.py new file mode 100644 index 0000000..06a6d9e --- /dev/null +++ b/commonthread/ct_application/ml/perplexity_service.py @@ -0,0 +1,69 @@ +import requests +import json +import logging + +logger = logging.getLogger(__name__) + +def get_perplexity_chat_response(api_key: str, context: str, user_message: str) -> dict: + """ + Calls the Perplexity API with the given context and user message. + + Args: + api_key: The Perplexity API key. + context: The project context (concatenated stories). + user_message: The user's message/question. + + Returns: + A dictionary containing the Perplexity API JSON response or an error message. + """ + perplexity_api_url = "https://api.perplexity.ai/chat/completions" + model_name = "sonar" # Or any other suitable model + + perplexity_payload = { + "model": model_name, + "messages": [ + {"role": "system", "content": f"You are a helpful assistant. Your task is to answer questions about a project based ONLY on the following provided project stories. Do not use any external knowledge or make assumptions beyond what is written in these stories. If the answer to the question cannot be found within the provided stories, you MUST explicitly state that you cannot answer based on the provided information and should not attempt to answer generally.\n\nProject Stories Context:\n{context}"}, + {"role": "user", "content": user_message} + ] + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + try: + response = requests.post(perplexity_api_url, json=perplexity_payload, headers=headers) + response.raise_for_status() # Raises an HTTPError for bad responses (4XX or 5XX) + return response.json() + except requests.exceptions.HTTPError as e: + logger.error(f"Perplexity API HTTPError: {e.response.status_code} {e.response.reason}. Response: {e.response.text}") + error_detail = "No additional detail from server." + try: + error_detail = e.response.json().get('error', {}).get('message', e.response.text) + except json.JSONDecodeError: + error_detail = e.response.text + return {"error": f"API request failed: {e.response.status_code} {e.response.reason}", "details": error_detail, "status_code": e.response.status_code} + except requests.exceptions.ConnectionError as e: + logger.error(f"Perplexity API ConnectionError: {e}") + return {"error": "Could not connect to Perplexity API.", "details": str(e), "status_code": 503} # Service Unavailable + except requests.exceptions.Timeout as e: + logger.error(f"Perplexity API Timeout: {e}") + return {"error": "Request to Perplexity API timed out.", "details": str(e), "status_code": 504} # Gateway Timeout + except requests.exceptions.RequestException as e: + logger.error(f"Perplexity API RequestException: {e}") + return {"error": "An unexpected error occurred while communicating with Perplexity API.", "details": str(e), "status_code": 500} + except json.JSONDecodeError as e: # Should not happen if Perplexity API is consistent, but good to have + logger.error(f"Failed to decode Perplexity API JSON response: {e}") + return {"error": "Failed to decode Perplexity API response.", "details": str(e), "status_code": 500} + +if __name__ == '__main__': + # This part is for testing the service directly, if needed. + # You'd need to set a dummy API key and provide some context/message. + # For example: + # test_api_key = "YOUR_TEST_API_KEY" + # test_context = "Story 1: The project is about cats. Story 2: Cats are fluffy." + # test_user_message = "What is the project about?" + # response = get_perplexity_chat_response(test_api_key, test_context, test_user_message) + # print(response) + pass diff --git a/commonthread/ct_application/tests/backend/test_chat_api.py b/commonthread/ct_application/tests/backend/test_chat_api.py new file mode 100644 index 0000000..e3beefc --- /dev/null +++ b/commonthread/ct_application/tests/backend/test_chat_api.py @@ -0,0 +1,318 @@ +import json +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from unittest.mock import patch, ANY +from django.conf import settings + +from commonthread.ct_application.models import Project, Story, Organization + +User = get_user_model() + +class ProjectChatAPITests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='testuser', password='password123', email='test@example.com', name='Test User') + # Note: Client().login() returns True if login is successful, False otherwise. + # We don't need to assign it to self.client if we are creating a new client instance each time or just using self.client + login_successful = self.client.login(username='testuser', password='password123') + self.assertTrue(login_successful, "Client login failed") + + self.organization = Organization.objects.create(name='Test Org') + self.project = Project.objects.create(name='Test Project', org=self.organization, date='2023-01-01', curator=self.user) + + # Create some stories for context + Story.objects.create(proj=self.project, text_content="First story about cats.", storyteller="User1", curator=self.user, date="2023-01-02") + Story.objects.create(proj=self.project, text_content="Second story about dogs.", storyteller="User2", curator=self.user, date="2023-01-03") + + self.chat_url = reverse('project-chat-api', kwargs={'project_id': self.project.id}) + self.valid_payload = {"user_message": "Hello AI"} + + def test_chat_api_unauthenticated(self): + self.client.logout() + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + # The verify_user decorator returns 299 if token is expired, 401 if no token or bad token + # For a logged-out client (no session, no token in header for API calls), it should be 401 + self.assertEqual(response.status_code, 401) # Or 299 if verify_user behaves that way without a token + + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_chat_api_success(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": [{"message": {"content": "Mocked AI response"}}], + "usage": {"total_tokens": 50} # Example other data + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json.get('reply'), "Mocked AI response") + + expected_context = "First story about cats.\n\nSecond story about dogs." + mock_get_perplexity_chat_response.assert_called_once_with( + settings.PERPLEXITY_API_KEY, + expected_context, + self.valid_payload['user_message'] + ) + + def test_chat_api_missing_message(self): + response = self.client.post(self.chat_url, {}, content_type='application/json') + self.assertEqual(response.status_code, 400) + response_json = response.json() + self.assertIn("Missing user_message", response_json.get('error', '')) + + def test_chat_api_invalid_json(self): + response = self.client.post(self.chat_url, "this is not json", content_type='application/json') + self.assertEqual(response.status_code, 400) + response_json = response.json() + self.assertIn("Invalid JSON", response_json.get('error', '')) + + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_chat_api_perplexity_service_error(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "error": "Perplexity API unavailable", + "details": "Service is down", + "status_code": 503 + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 503) + response_json = response.json() + self.assertEqual(response_json.get('error'), "Failed to get response from AI service") + self.assertIn("Perplexity API unavailable", response_json.get('details', '')) + + def test_chat_api_project_not_found(self): + # Create a URL for a non-existent project ID + # Assuming UUIDs for IDs, generate a new one or use a large integer if using integer IDs + non_existent_project_id = self.project.id + 999 # Or a random UUID if using UUIDs + chat_url_not_found = reverse('project-chat-api', kwargs={'project_id': non_existent_project_id}) + + # Make the POST request + response = self.client.post(chat_url_not_found, self.valid_payload, content_type='application/json') + + # Check the response status code. + # The verify_user decorator's id_searcher calls check_project_auth, + # which returns a JsonResponse with status 404 if Project.DoesNotExist. + # The auth_level_check then would receive this JsonResponse and return it. + self.assertEqual(response.status_code, 404) + response_json = response.json() + self.assertIn("Project not found", response_json.get('error', '')) + + # Test for when Perplexity response is missing the expected 'content' + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_chat_api_perplexity_malformed_success_response(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": [{"message": {"text_instead_of_content": "Mocked AI response"}}], # Malformed + "usage": {"total_tokens": 50} + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 500) # Internal server error due to unexpected structure + response_json = response.json() + self.assertTrue(response_json.get('error', '').startswith("Failed to get a valid response from AI service")) + + # Test for when Perplexity response structure is completely different (e.g. choices is not a list) + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_chat_api_perplexity_very_malformed_response(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": "not-a-list", # Malformed + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 500) # Internal server error + response_json = response.json() + self.assertTrue(response_json.get('error', '').startswith("An unexpected server error occurred while processing AI response")) + + # Test with no stories in the project (empty context) + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_chat_api_success_no_stories(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": [{"message": {"content": "Mocked AI response for empty context"}}], + } + # Delete existing stories for this project for this test + Story.objects.filter(proj=self.project).delete() + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json.get('reply'), "Mocked AI response for empty context") + + expected_context = "" # Empty context + mock_get_perplexity_chat_response.assert_called_once_with( + settings.PERPLEXITY_API_KEY, + expected_context, + self.valid_payload['user_message'] + ) + + # Test with a POST request that is not JSON + def test_chat_api_non_json_post(self): + response = self.client.post(self.chat_url, data="user_message=Hello", content_type='text/plain') + self.assertEqual(response.status_code, 400) + response_json = response.json() + self.assertIn("Invalid JSON", response_json.get('error', '')) + + # Test that the view only accepts POST requests + def test_chat_api_get_request_not_allowed(self): + response = self.client.get(self.chat_url) + self.assertEqual(response.status_code, 405) # Method Not Allowed + response_json = response.json() + self.assertIn("Method \"GET\" not allowed.", response_json.get('detail', '')) + + def test_chat_api_put_request_not_allowed(self): + response = self.client.put(self.chat_url, self.valid_payload, content_type='application/json') + self.assertEqual(response.status_code, 405) # Method Not Allowed + response_json = response.json() + self.assertIn("Method \"PUT\" not allowed.", response_json.get('detail', '')) + + def test_chat_api_delete_request_not_allowed(self): + response = self.client.delete(self.chat_url) + self.assertEqual(response.status_code, 405) # Method Not Allowed + response_json = response.json() + self.assertIn("Method \"DELETE\" not allowed.", response_json.get('detail', '')) + + # Test with a different user who does not have access to the project (if verify_user checks this) + # This depends on how verify_user and id_searcher determine project access for the chat API. + # The current verify_user checks based on project_id in kwargs, so if the user has a valid session + # but the id_searcher determines they don't have 'user' level access to this project_id, it should fail. + # Let's assume for now that any authenticated user has 'user' access to any project for chat. + # If a more granular check is implemented in id_searcher, this test would need adjustment. + # The current id_searcher for a project_id in kwargs calls check_project_auth, which checks OrgUser access. + # So, we need to ensure the user is part of the org with at least 'user' rights. + # For simplicity, the setUp user is the curator, which implies access. + # To test this properly, one might need another user not in the org, or in the org with insufficient perms. + + # The PERPLEXITY_API_KEY needs to be set in settings for tests, or mocked if it's accessed directly + # For now, assuming it's set. If not, tests might fail if settings.PERPLEXITY_API_KEY is None. + # It's better to ensure it's set for tests or explicitly mock settings.PERPLEXITY_API_KEY. + # settings.PERPLEXITY_API_KEY = "dummy_test_key" # Can be done in setUp or a test-specific settings override + + def tearDown(self): + # Clean up any created objects if necessary, though Django's test runner handles transaction rollbacks. + pass + + +class StoryChatAPITests(TestCase): + def setUp(self): + self.client = Client() # Ensure a fresh client for this test class + self.user = User.objects.create_user(username='teststoryuser', password='password123', email='teststory@example.com', name='Test Story User') + login_successful = self.client.login(username='teststoryuser', password='password123') + self.assertTrue(login_successful, "Client login for StoryChatAPITests failed") + + self.organization = Organization.objects.create(name='Test Org For Story Chat') + # Ensure project curator is set if your models/logic require it, using self.user + self.project = Project.objects.create(name='Test Project For Story Chat', org=self.organization, date='2023-01-01', curator=self.user) + + self.story_content = "This is the specific content of our test story for chat." + self.story = Story.objects.create( + proj=self.project, + storyteller="Test Storyteller", + date='2023-01-02', + text_content=self.story_content, + curator=self.user # Ensure curator is set + ) + self.empty_story = Story.objects.create( + proj=self.project, + storyteller="Empty", + date='2023-01-03', + text_content="", # Empty content + curator=self.user # Ensure curator is set + ) + self.chat_url = reverse('story-chat-api', kwargs={'story_id': self.story.id}) + self.empty_story_chat_url = reverse('story-chat-api', kwargs={'story_id': self.empty_story.id}) + # For non_existent_story_chat_url, it's better to use an ID that is unlikely to exist. + # Using self.story.id + 999 might clash if many stories are created. A fixed large number is safer. + self.non_existent_story_chat_url = reverse('story-chat-api', kwargs={'story_id': 999999}) + self.valid_payload = {"user_message": "Tell me about this story"} + + def test_story_chat_api_unauthenticated(self): + self.client.logout() + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + self.assertEqual(response.status_code, 401) # verify_user returns 401 for no token + + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_story_chat_api_success(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": [{"message": {"content": "Mocked story AI response"}}] + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json.get('reply'), "Mocked story AI response") + + mock_get_perplexity_chat_response.assert_called_once_with( + settings.PERPLEXITY_API_KEY, + self.story_content, # Expected context is the story's text_content + self.valid_payload['user_message'] + ) + + def test_story_chat_api_story_not_found(self): + response = self.client.post(self.non_existent_story_chat_url, self.valid_payload, content_type='application/json') + # The view's try-except Story.DoesNotExist should catch this before verify_user's id_searcher for story_id. + self.assertEqual(response.status_code, 404) + response_json = response.json() + self.assertIn("Story not found", response_json.get('error', '')) + + + def test_story_chat_api_missing_message(self): + response = self.client.post(self.chat_url, {}, content_type='application/json') + self.assertEqual(response.status_code, 400) + response_json = response.json() + self.assertIn("Missing user_message", response_json.get('error', '')) + + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_story_chat_api_service_error(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "error": "Service down", + "details": "Perplexity is napping", # Added details to match view's expectation + "status_code": 503 + } + + response = self.client.post(self.chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 503) + response_json = response.json() + self.assertEqual(response_json.get('error'), "Failed to get response from AI service.") + self.assertEqual(response_json.get('details'), "Service down") # Check details propagation + + @patch('commonthread.ct_application.ml.perplexity_service.get_perplexity_chat_response') + def test_story_chat_api_empty_story_content(self, mock_get_perplexity_chat_response): + mock_get_perplexity_chat_response.return_value = { + "choices": [{"message": {"content": "Response for empty story"}}] + } + + response = self.client.post(self.empty_story_chat_url, self.valid_payload, content_type='application/json') + + self.assertEqual(response.status_code, 200) + response_json = response.json() + self.assertEqual(response_json.get('reply'), "Response for empty story") + + mock_get_perplexity_chat_response.assert_called_once_with( + settings.PERPLEXITY_API_KEY, + "", # Expected context is an empty string + self.valid_payload['user_message'] + ) + + def test_story_chat_api_disallowed_methods(self): + methods = { + "GET": self.client.get, + "PUT": lambda url, **kwargs: self.client.put(url, data=json.dumps(self.valid_payload), content_type='application/json', **kwargs), # PUT needs data + "DELETE": self.client.delete, + "PATCH": lambda url, **kwargs: self.client.patch(url, data=json.dumps(self.valid_payload), content_type='application/json', **kwargs) # PATCH needs data + } + for method_name, method_func in methods.items(): + with self.subTest(method=method_name): + response = method_func(self.chat_url) + self.assertEqual(response.status_code, 405) # Method Not Allowed + response_json = response.json() + # The exact detail message can vary slightly depending on Django version or specific configuration + # For Django 4.x/5.x, it's usually "Method \"METHOD\" not allowed." + self.assertTrue(response_json.get('detail', '').startswith(f'Method "{method_name}" not allowed.')) + + def tearDown(self): + # Clean up any created objects if necessary + pass diff --git a/commonthread/ct_application/views.py b/commonthread/ct_application/views.py index f94ca84..3a8c512 100644 --- a/commonthread/ct_application/views.py +++ b/commonthread/ct_application/views.py @@ -50,8 +50,13 @@ from django.utils import timezone from django.db import transaction from django.db.models import Prefetch +feat/project-chat-perplexity +# import requests # Removed, as it's now in perplexity_service +from django.conf import settings +from .ml.perplexity_service import get_perplexity_chat_response # Added from botocore.exceptions import ClientError, ParamValidationError + # HANDLERES SET UP ------------------------------------------------------------- import traceback from commonthread.settings import JWT_SECRET_KEY @@ -67,6 +72,98 @@ # VIEWS ------------------------------------------------------------------------ +@csrf_exempt +@verify_user('user') +def project_chat_api(request, project_id): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + try: + data = json.loads(request.body) + user_message = data.get("user_message") + if not user_message: + return JsonResponse({"error": "Missing user_message"}, status=400) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + stories = Story.objects.filter(proj_id=project_id).values_list('text_content', flat=True) + context = "\n\n".join(stories) + # TODO: Implement truncation or summarization if context exceeds Perplexity's limits. + + # Call the Perplexity service + response_data = get_perplexity_chat_response(settings.PERPLEXITY_API_KEY, context, user_message) + + if "error" in response_data: + logger.error(f"Error from Perplexity service: {response_data}") + return JsonResponse( + {"error": "Failed to get response from AI service", "details": response_data.get("details", response_data.get("error"))}, + status=response_data.get("status_code", 500) + ) + + try: + # Adjust the following line based on the actual structure of Perplexity's response + # This structure is based on the successful response from the service + ai_response = response_data.get('choices', [{}])[0].get('message', {}).get('content', '') + if not ai_response: + logger.error(f"Perplexity service response did not contain the expected data structure: {response_data}") + api_error_message = response_data.get('error', {}).get('message', 'Error processing Perplexity response.') + return JsonResponse({"error": f"Failed to get a valid response from AI service: {api_error_message}"}, status=500) + + return JsonResponse({"reply": ai_response}) + + except Exception as e: # Catch any other unexpected errors during response parsing + logger.error(f"Unexpected error processing Perplexity service response in view: {e}. Response data: {response_data}") + return JsonResponse({"error": "An unexpected server error occurred while processing AI response."}, status=500) + +@csrf_exempt +@verify_user('user') +def story_chat_api(request, story_id): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + try: + data = json.loads(request.body) + user_message = data.get("user_message") + if not user_message: + return JsonResponse({"error": "Missing user_message"}, status=400) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + try: + story = Story.objects.get(id=story_id) + context = story.text_content + if not context.strip(): + logger.warning(f"Story {story_id} has no text_content. Chat context will be empty.") + # Proceeding with potentially empty context as per instructions + except Story.DoesNotExist: + logger.error(f"Story with id={story_id} not found for chat.") + return JsonResponse({"error": "Story not found."}, status=404) + + # Temporary logging + logger.info(f"[STORY_CHAT_DEBUG] Story ID: {story_id}") + logger.info(f"[STORY_CHAT_DEBUG] Context preview (first 500 chars): {context[:500]}") + + # Call the Perplexity service + response_data = get_perplexity_chat_response(settings.PERPLEXITY_API_KEY, context, user_message) + + if "error" in response_data: + logger.error(f"Error from Perplexity service for story {story_id}: {response_data}") + return JsonResponse({ + "error": "Failed to get response from AI service.", + "details": response_data.get("error") # Matching the structure from project_chat_api's error details + }, status=response_data.get("status_code", 500)) + + try: + ai_reply = response_data.get("choices", [{}])[0].get("message", {}).get("content") + if not ai_reply: + logger.error(f"Could not extract AI reply from Perplexity response for story {story_id}: {response_data}") + return JsonResponse({"error": "Failed to parse AI response."}, status=500) + + return JsonResponse({"reply": ai_reply}) + except Exception as e: # Catch any other unexpected errors during response parsing + logger.error(f"Unexpected error processing Perplexity service response in story_chat_api view: {e}. Response data: {response_data}") + return JsonResponse({"error": "An unexpected server error occurred while processing AI response."}, status=500) + ## Home test ------------------------------------------------------------------- diff --git a/frontend/src/lib/components/Chatbox.svelte b/frontend/src/lib/components/Chatbox.svelte new file mode 100644 index 0000000..756b44c --- /dev/null +++ b/frontend/src/lib/components/Chatbox.svelte @@ -0,0 +1,208 @@ + + +
+
+ {#each messages as message (message.id)} +
+ {message.text} +
+ {/each} + {#if isLoading} +
AI is thinking...
+ {/if} +
+ {#if error} +
+

Error: {error}

+
+ {/if} +
+ e.key === 'Enter' && sendMessage()} + placeholder="Type your message..." + disabled={isLoading} + /> + +
+
+ + diff --git a/frontend/src/lib/components/Chatbox.test.js b/frontend/src/lib/components/Chatbox.test.js new file mode 100644 index 0000000..74c45c5 --- /dev/null +++ b/frontend/src/lib/components/Chatbox.test.js @@ -0,0 +1,184 @@ +// Chatbox.test.js +import { writable } from 'svelte/store'; +import { vi } from 'vitest'; + +// Mock Svelte stores from $lib/store.js +const mockAccessToken = writable('fake-access-token'); +const mockRefreshToken = writable('fake-refresh-token'); +vi.mock('$lib/store.js', () => ({ + __esModule: true, // This is important for ES Modules + accessToken: mockAccessToken, + refreshToken: mockRefreshToken, + // Mock other exports from store.js if Chatbox uses them, else they can be undefined or vi.fn() + // For example, if store.js also exports an 'ipAddress' store: + ipAddress: writable('http://localhost:8000') +})); + +// Mock authRequest +vi.mock('$lib/authRequest.js', () => ({ + __esModule: true, + authRequest: vi.fn() +})); + +// Now the tests... +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import Chatbox from './Chatbox.svelte'; // Path to component + +describe('Chatbox.svelte', () => { + const projectId = 'test-project-123'; + let authRequestMock; + + beforeEach(async () => { + vi.clearAllMocks(); // Clear all mocks + + // Re-import the mocked authRequest to get the vi.fn() instance for this test scope + const authRequestModule = await import('$lib/authRequest.js'); + authRequestMock = authRequestModule.authRequest; + + authRequestMock.mockResolvedValue({ // Default mock for successful calls + data: { reply: "Mocked AI response" }, + newAccessToken: null + }); + + // Reset store values + mockAccessToken.set('fake-access-token'); + mockRefreshToken.set('fake-refresh-token'); + }); + + test('renders initial state correctly', () => { + render(Chatbox, { props: { projectId } }); + expect(screen.getByPlaceholderText('Type your message...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument(); + }); + + test('sends a message and displays user and AI responses', async () => { + render(Chatbox, { props: { projectId } }); + + const input = screen.getByPlaceholderText('Type your message...'); + const sendButton = screen.getByRole('button', { name: 'Send' }); + + await fireEvent.input(input, { target: { value: 'Hello AI' } }); + await fireEvent.click(sendButton); + + // Check if user message appears + // Note: The component wraps messages in divs with class based on sender. + // We can check for the text content within elements having the 'user-message' class. + const userMessages = screen.getAllByText('Hello AI'); + expect(userMessages.some(el => el.closest('.user-message'))).toBe(true); + + + // Check if authRequest was called + expect(authRequestMock).toHaveBeenCalledWith( + `/project/${projectId}/chat`, + 'POST', + 'fake-access-token', // This comes from the mockAccessToken + 'fake-refresh-token', // This comes from the mockRefreshToken + { user_message: 'Hello AI' } + ); + + // Wait for AI response to appear + await waitFor(() => { + const aiMessages = screen.getAllByText('Mocked AI response'); + expect(aiMessages.some(el => el.closest('.ai-message'))).toBe(true); + }); + + // Check if input is cleared + expect(input.value).toBe(''); + }); + + test('shows loading indicator while sending message', async () => { + authRequestMock.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ data: { reply: "Done" } }), 100))); // Delayed response + + render(Chatbox, { props: { projectId } }); + const input = screen.getByPlaceholderText('Type your message...'); + await fireEvent.input(input, { target: { value: 'Test loading' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + // User message should be visible + const userMessages = screen.getAllByText('Test loading'); + expect(userMessages.some(el => el.closest('.user-message'))).toBe(true); + + // Check for loading message (AI is thinking...) + expect(screen.getByText('AI is thinking...')).toBeInTheDocument(); + // Check if send button is disabled + expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled(); + + + await waitFor(() => { + const aiMessages = screen.getAllByText('Done'); + expect(aiMessages.some(el => el.closest('.ai-message'))).toBe(true); + }); + expect(screen.getByRole('button', { name: 'Send' })).not.toBeDisabled(); + // Ensure "AI is thinking..." message is gone + expect(screen.queryByText('AI is thinking...')).not.toBeInTheDocument(); + }); + + test('displays error message if sending fails due to network/server error', async () => { + authRequestMock.mockRejectedValue(new Error('Network Error')); + + render(Chatbox, { props: { projectId } }); + const input = screen.getByPlaceholderText('Type your message...'); + await fireEvent.input(input, { target: { value: 'Error test' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + // The component adds "Error: " + error message to messages array with sender 'system' + const errorMessages = screen.getAllByText('Error: Network Error'); + expect(errorMessages.some(el => el.closest('.system-message'))).toBe(true); + }); + // Also check the dedicated error display area + expect(screen.getByText('Error: Network Error', { selector: '.error-message p' })).toBeInTheDocument(); + }); + + test('displays error message if API returns an error structure', async () => { + authRequestMock.mockResolvedValue({ + error: { message: "API Error Detail" } + }); + + render(Chatbox, { props: { projectId } }); + const input = screen.getByPlaceholderText('Type your message...'); + await fireEvent.input(input, { target: { value: 'API error test' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + const errorMessages = screen.getAllByText('Error: API Error Detail'); + expect(errorMessages.some(el => el.closest('.system-message'))).toBe(true); + }); + // Also check the dedicated error display area + expect(screen.getByText('Error: API Error Detail', { selector: '.error-message p' })).toBeInTheDocument(); + }); + + + test('does not send message if input is empty', async () => { + render(Chatbox, { props: { projectId } }); + + const sendButton = screen.getByRole('button', { name: 'Send' }); + await fireEvent.click(sendButton); + + expect(authRequestMock).not.toHaveBeenCalled(); + }); + + test('updates access token if new one is received', async () => { + authRequestMock.mockResolvedValue({ + data: { reply: "Response with new token" }, + newAccessToken: 'new-fake-access-token' + }); + + render(Chatbox, { props: { projectId } }); + const input = screen.getByPlaceholderText('Type your message...'); + await fireEvent.input(input, { target: { value: 'Token refresh test' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(screen.getByText('Response with new token')).toBeInTheDocument(); + }); + + let currentTokenValue; + const unsubscribe = mockAccessToken.subscribe(value => { // Subscribe to the Svelte store + currentTokenValue = value; + }); + unsubscribe(); // Immediately unsubscribe after getting the value + + expect(currentTokenValue).toBe('new-fake-access-token'); + }); +}); diff --git a/frontend/src/lib/components/ProjectCard.svelte b/frontend/src/lib/components/ProjectCard.svelte index 12ea743..e580f3d 100644 --- a/frontend/src/lib/components/ProjectCard.svelte +++ b/frontend/src/lib/components/ProjectCard.svelte @@ -4,6 +4,9 @@ import { page } from '$app/stores'; let { project } = $props(); + feat/project-chat-perplexity + console.log('ProjectCard project prop:', project); +
diff --git a/frontend/src/routes/org/[org_id]/project/[project_id]/+page.svelte b/frontend/src/routes/org/[org_id]/project/[project_id]/+page.svelte index 940ac7c..947f27a 100644 --- a/frontend/src/routes/org/[org_id]/project/[project_id]/+page.svelte +++ b/frontend/src/routes/org/[org_id]/project/[project_id]/+page.svelte @@ -5,6 +5,7 @@ import ProjectHeader from '$lib/components/ProjectHeader.svelte'; import DataDashboard from '$lib/components/DataDashboard.svelte'; import StoryPreview from '$lib/components/StoryPreview.svelte'; + import Chatbox from '$lib/components/Chatbox.svelte'; // Added Chatbox import import { authRequest } from '$lib/authRequest.js'; import { onMount } from 'svelte'; @@ -24,11 +25,16 @@ let type = $state('dash'); let searchValue = $state(''); let storiesTotalSearch = $state(0); + + console.log('Project Page - $page.params.project_id:', $page.params.project_id); + const projectId = $page.params.project_id; // Ensure projectId is explicitly defined if not already + const projectChatApiEndpoint = `/project/${projectId}/chat`; // Create the endpoint URL + console.log('Project Page - constructed projectChatApiEndpoint:', projectChatApiEndpoint); let isLoading = $state(true); let initialLoad = $state(true); // To handle initial loading state - const org_id = $page.params.org_id; + $inspect(projectData); $inspect(stories); $inspect(searchValue); @@ -241,6 +247,11 @@
{/if} + +
+ +
+ diff --git a/frontend/src/routes/org/[org_id]/story/[story_id]/+page.svelte b/frontend/src/routes/org/[org_id]/story/[story_id]/+page.svelte index 1ee3d43..2be03da 100644 --- a/frontend/src/routes/org/[org_id]/story/[story_id]/+page.svelte +++ b/frontend/src/routes/org/[org_id]/story/[story_id]/+page.svelte @@ -6,6 +6,7 @@ import StoryFullView from '$lib/components/StoryFullView.svelte'; import AudioPlayer from '$lib/components/audio/AudioPlayer.svelte'; import OrgHeader from '$lib/components/OrgHeader.svelte'; + import Chatbox from '$lib/components/Chatbox.svelte'; // Added Chatbox import import { accessToken, refreshToken } from '$lib/store.js'; import { authRequest } from '$lib/authRequest.js'; import { showError } from '$lib/errorStore.js'; @@ -44,7 +45,13 @@ $inspect(orgData); $inspect(storyData); - // API call with error handling +feat/project-chat-perplexity + // Construct chat API endpoint + console.log('Story Page - $page.params.story_id:', $page.params.story_id); + const story_id_for_endpoint = $page.params.story_id; // Ensure story_id is explicitly defined for clarity + const chatApiEndpoint = `/story/${story_id_for_endpoint}/chat`; + console.log('Story Page - constructed chatApiEndpoint:', chatApiEndpoint); + onMount(async () => { try { console.log('Making requests for org:', org_id, 'story:', story_id); @@ -217,6 +224,24 @@ justify-content: center; } + .story-chat-container { + margin-top: 30px; /* Increased margin-top */ + padding: 20px; /* Increased padding */ + border: 1px solid #ddd; /* Slightly darker border */ + border-radius: 8px; /* Added border-radius for rounded corners */ + background-color: #f9f9f9; /* Light background color for the container */ + max-width: 700px; /* Max width for the chat container */ + margin-left: auto; /* Center the chat container */ + margin-right: auto; /* Center the chat container */ + } + + .story-chat-container h3 { + font-size: 1.5em; + color: #333; + margin-bottom: 15px; + text-align: center; + } + #images { /* max-height: 300px; */ max-width: 100%; @@ -259,3 +284,9 @@ padding: 2rem; } + + +
+

Chat about this Story

+ +