diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b4ee123..8cbad0b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,6 +8,7 @@ import os import secrets import tomllib +from base64 import urlsafe_b64encode from pathlib import Path from typing import Annotated, Any, Literal @@ -125,8 +126,10 @@ def all_cors_origins(self) -> list[str]: # OAuth Integration Configuration # Token encryption key for secure storage of OAuth tokens - # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" - TOKEN_ENCRYPTION_KEY: str | None = None + # Auto-generated for local/dev (tokens won't survive key changes across restarts). + # For production, set explicitly and persist: + # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + TOKEN_ENCRYPTION_KEY: str = urlsafe_b64encode(secrets.token_bytes(32)).decode() # LLM API Keys # Used by the backend to inject application-level API keys into flows via tweaks. diff --git a/backend/app/core/encryption.py b/backend/app/core/encryption.py index 97638c5..097508e 100644 --- a/backend/app/core/encryption.py +++ b/backend/app/core/encryption.py @@ -36,20 +36,12 @@ def __init__(self, key: str | None = None): Args: key: Base64-encoded 32-byte key. If not provided, - reads from TOKEN_ENCRYPTION_KEY setting. - - Raises: - ValueError: If no key is provided or found in settings. + reads from TOKEN_ENCRYPTION_KEY setting (which auto-generates + a default for local/dev environments). """ if key is None: key = settings.TOKEN_ENCRYPTION_KEY - if not key: - raise ValueError( - "TOKEN_ENCRYPTION_KEY environment variable is required. " - "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" - ) - self.fernet = Fernet(key.encode() if isinstance(key, str) else key) def encrypt(self, plaintext: str) -> bytes: diff --git a/backend/tests/models/test_user_integration.py b/backend/tests/models/test_user_integration.py index 1bd8ddc..64d955e 100644 --- a/backend/tests/models/test_user_integration.py +++ b/backend/tests/models/test_user_integration.py @@ -46,13 +46,13 @@ def test_decrypt_invalid_data_raises_error(self): with pytest.raises(InvalidToken): encryption.decrypt(b"invalid-encrypted-data") - def test_encryption_without_key_raises_error(self): - """Missing TOKEN_ENCRYPTION_KEY raises ValueError.""" + def test_encryption_with_invalid_key_raises_error(self): + """Invalid encryption key raises ValueError.""" from app.core.encryption import TokenEncryption - # Explicitly pass empty key to test error handling - with pytest.raises(ValueError, match="TOKEN_ENCRYPTION_KEY"): - TokenEncryption(key="") + # Explicitly pass invalid key to test error handling + with pytest.raises(ValueError): + TokenEncryption(key="not-a-valid-fernet-key") class TestUserIntegrationModel: diff --git a/config/local/.env.example b/config/local/.env.example index 6a77b73..58e3a49 100644 --- a/config/local/.env.example +++ b/config/local/.env.example @@ -58,9 +58,19 @@ LANGFLOW_URL=http://localhost:7860 # GOOGLE_API_KEY= # GOOGLE_CSE_ID= +# ======================================== +# Token Encryption (auto-generated for local dev) +# ======================================== +# Fernet key for encrypting stored OAuth tokens (Google Drive, Dataverse, etc.) +# Auto-generated if not set. Set explicitly in production to persist across restarts. +# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# TOKEN_ENCRYPTION_KEY= + # ======================================== # Advanced (rarely changed) # ======================================== -# ENVIRONMENT=development -# FRONTEND_HOST=http://localhost:4180 -# BACKEND_CORS_ORIGINS=http://localhost:4180,http://localhost:8080,http://localhost:5173 +# Default is "local" (no OAuth, dev-user at port 8080). +# Set to "development" to enable OAuth via proxy at port 4180. +ENVIRONMENT=development +FRONTEND_HOST=http://localhost:4180 +BACKEND_CORS_ORIGINS=http://localhost:4180,http://localhost:8080,http://localhost:5173 diff --git a/frontend/src/app/Chat/Chat.test.tsx b/frontend/src/app/Chat/Chat.test.tsx index a784885..b531135 100644 --- a/frontend/src/app/Chat/Chat.test.tsx +++ b/frontend/src/app/Chat/Chat.test.tsx @@ -206,4 +206,95 @@ describe('Chat component', () => { // Component should not crash and should handle the error expect(screen.getByText('Research Assistant')).toBeVisible(); }); + + test('should display actual error message from SSE error event', async () => { + vi.mocked(ChatAPI.getMessages).mockResolvedValue({ data: [], count: 0 }); + + vi.mocked(ChatAPI.createStreamingMessage).mockImplementation( + (_chatId, _content, onMessage) => { + setTimeout(() => { + onMessage({ type: 'error', error: 'Failed to connect to Langflow: Connection refused' }); + }, 10); + return { close: vi.fn() }; + } + ); + + renderChat(); + + await waitFor(() => { + expect(ChatAPI.getChats).toHaveBeenCalled(); + }); + + // Simulate sending by calling the streaming mock directly and checking the onMessage callback + const onMessage = vi.fn(); + ChatAPI.createStreamingMessage(1, 'test', onMessage); + + await waitFor(() => { + expect(onMessage).toHaveBeenCalledWith({ + type: 'error', + error: 'Failed to connect to Langflow: Connection refused', + }); + }); + }); + + test('should pass partial content and error separately when error occurs mid-stream', async () => { + vi.mocked(ChatAPI.getMessages).mockResolvedValue({ data: [], count: 0 }); + + vi.mocked(ChatAPI.createStreamingMessage).mockImplementation( + (_chatId, _content, onMessage) => { + setTimeout(() => { + onMessage({ type: 'content', content: 'Here is the beginning' }); + onMessage({ type: 'error', error: 'Langflow streaming error: 500' }); + }, 10); + return { close: vi.fn() }; + } + ); + + renderChat(); + + await waitFor(() => { + expect(ChatAPI.getChats).toHaveBeenCalled(); + }); + + // Verify the streaming API delivers both content and error events + const events: Array<{ type: string; content?: string; error?: string }> = []; + ChatAPI.createStreamingMessage(1, 'test', (event) => events.push(event)); + + await waitFor(() => { + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ type: 'content', content: 'Here is the beginning' }); + expect(events[1]).toEqual({ type: 'error', error: 'Langflow streaming error: 500' }); + }); + }); + + test('should pass network error message through to error handler', async () => { + vi.mocked(ChatAPI.getMessages).mockResolvedValue({ data: [], count: 0 }); + + vi.mocked(ChatAPI.createStreamingMessage).mockImplementation( + (_chatId, _content, _onMessage, onError) => { + setTimeout(() => { + onError?.(new Error('Missing integration: jira. Please connect the service in Settings.')); + }, 10); + return { close: vi.fn() }; + } + ); + + renderChat(); + + await waitFor(() => { + expect(ChatAPI.getChats).toHaveBeenCalled(); + }); + + // Verify the onError callback receives the actual error message + const onError = vi.fn(); + ChatAPI.createStreamingMessage(1, 'test', vi.fn(), onError); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Missing integration: jira. Please connect the service in Settings.', + }) + ); + }); + }); }); diff --git a/frontend/src/app/Chat/Chat.tsx b/frontend/src/app/Chat/Chat.tsx index 01d3233..9a8c8f1 100644 --- a/frontend/src/app/Chat/Chat.tsx +++ b/frontend/src/app/Chat/Chat.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Alert, AlertActionCloseButton, + AlertActionLink, Button, Dropdown, DropdownItem, @@ -28,7 +29,7 @@ import { MessageProps, Conversation, } from '@patternfly/chatbot'; -import { ArrowDownIcon, RedoIcon, TrashIcon } from '@patternfly/react-icons'; +import { ArrowDownIcon, TrashIcon } from '@patternfly/react-icons'; import { ChatAPI, Chat as ChatType, ChatMessage, StreamingEvent, Flow } from './chatApi'; import userAvatar from '@app/images/user-avatar.svg'; @@ -37,7 +38,6 @@ import aiLogo from '@app/images/ai-logo-transparent.svg'; import '@patternfly/chatbot/dist/css/main.css'; import './Chat.css'; -const ERROR_MESSAGE = 'Sorry, an error occurred. Click retry to try again.'; const DISPLAY_MODE = ChatbotDisplayMode.embedded; function convertMessageToProps(msg: ChatMessage): MessageProps { @@ -69,7 +69,13 @@ function Chat(): React.ReactElement { const [messages, setMessages] = React.useState([]); const [isSending, setIsSending] = React.useState(false); const [announcement, setAnnouncement] = React.useState(); - const [lastError, setLastError] = React.useState<{ message: string; chatId: number } | null>(null); + const [lastError, setLastError] = React.useState<{ + message: string; + chatId: number; + botMessageId: string; + errorText: string; + } | null>(null); + const [errorMessages, setErrorMessages] = React.useState>(new Map()); // Operation error state (for displaying errors to user) const [operationError, setOperationError] = React.useState(null); @@ -136,6 +142,8 @@ function Chat(): React.ReactElement { // Load messages and restore flow when chat changes React.useEffect(() => { + setLastError(null); + setErrorMessages(new Map()); if (selectedChatId) { const chat = chats.find((c) => c.id === selectedChatId); if (chat?.flow_name) { @@ -265,8 +273,9 @@ function Chat(): React.ReactElement { }; // Add loading bot message + const botMessageId = `bot-${Date.now()}`; const loadingBotMessage: MessageProps = { - id: `bot-${Date.now()}`, + id: botMessageId, role: 'bot', content: '', name: 'Assistant', @@ -277,8 +286,16 @@ function Chat(): React.ReactElement { if (isRetry) { // Remove the error message and add new loading message + const errorBotId = lastError?.botMessageId; + if (errorBotId) { + setErrorMessages((prev) => { + const next = new Map(prev); + next.delete(errorBotId); + return next; + }); + } setMessages((prev) => { - const withoutError = prev.filter((msg) => !msg.content?.includes('error occurred')); + const withoutError = prev.filter((msg) => msg.id !== errorBotId); return [...withoutError, loadingBotMessage]; }); } else { @@ -298,7 +315,7 @@ function Chat(): React.ReactElement { // Update the bot message with accumulated content setMessages((prev) => prev.map((msg) => - msg.id === loadingBotMessage.id + msg.id === botMessageId ? { ...msg, content: accumulatedContent, isLoading: false } : msg ) @@ -322,29 +339,43 @@ function Chat(): React.ReactElement { ); } } else if (event.type === 'error') { + const errorText = event.error || 'An unknown error occurred.'; streamControllerRef.current = null; setMessages((prev) => prev.map((msg) => - msg.id === loadingBotMessage.id - ? { ...msg, content: ERROR_MESSAGE, isLoading: false } + msg.id === botMessageId + ? { ...msg, content: accumulatedContent || '', isLoading: false } : msg ) ); - setLastError({ message: messageText, chatId: chatId }); + setErrorMessages((prev) => new Map(prev).set(botMessageId, errorText)); + setLastError({ + message: messageText, + chatId: chatId, + botMessageId, + errorText, + }); setIsSending(false); } }, (err) => { console.error('Streaming error:', err); + const errorText = err.message || 'An unknown error occurred.'; streamControllerRef.current = null; setMessages((prev) => prev.map((msg) => - msg.id === loadingBotMessage.id - ? { ...msg, content: ERROR_MESSAGE, isLoading: false } + msg.id === botMessageId + ? { ...msg, content: accumulatedContent || '', isLoading: false } : msg ) ); - setLastError({ message: messageText, chatId: chatId }); + setErrorMessages((prev) => new Map(prev).set(botMessageId, errorText)); + setLastError({ + message: messageText, + chatId: chatId, + botMessageId, + errorText, + }); setIsSending(false); }, () => { @@ -495,10 +526,18 @@ function Chat(): React.ReactElement { onScroll={handleScroll} > {messages.map((message) => { - const hasError = message.content?.includes('error occurred'); + const errorText = errorMessages.get(message.id || ''); + const hasError = !!errorText; + const hasPartialContent = hasError && !!message.content; const canRetry = hasError && lastError && lastError.chatId === selectedChatId; const showCopyAction = message.role === 'bot' && !message.isLoading && !hasError; + const retryLink = canRetry ? ( + + Retry + + ) : undefined; + return ( - {canRetry && ( - - )} - + {...(hasError && !hasPartialContent + ? { + error: { + title: errorText, + variant: 'danger', + actionLinks: retryLink, + }, + } + : {})} + {...(hasError && hasPartialContent + ? { + extraContent: { + afterMainContent: ( + + ), + }, + } + : {})} + /> ); })} diff --git a/frontend/src/app/Chat/chatApi.ts b/frontend/src/app/Chat/chatApi.ts index 7137b31..fe4e4d9 100644 --- a/frontend/src/app/Chat/chatApi.ts +++ b/frontend/src/app/Chat/chatApi.ts @@ -140,9 +140,18 @@ export const ChatAPI = { body: JSON.stringify(body), signal: controller.signal, }) - .then((response) => { + .then(async (response) => { if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + let detail = `HTTP error! status: ${response.status}`; + try { + const body = await response.json(); + if (body?.detail) { + detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail); + } + } catch { + // Response body wasn't JSON — use default status message + } + throw new Error(detail); } const reader = response.body?.getReader();