Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 2 additions & 10 deletions backend/app/core/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/models/test_user_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions config/local/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
91 changes: 91 additions & 0 deletions frontend/src/app/Chat/Chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
})
);
});
});
});
102 changes: 77 additions & 25 deletions frontend/src/app/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import {
Alert,
AlertActionCloseButton,
AlertActionLink,
Button,
Dropdown,
DropdownItem,
Expand All @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -69,7 +69,13 @@ function Chat(): React.ReactElement {
const [messages, setMessages] = React.useState<MessageProps[]>([]);
const [isSending, setIsSending] = React.useState(false);
const [announcement, setAnnouncement] = React.useState<string>();
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<Map<string, string>>(new Map());

// Operation error state (for displaying errors to user)
const [operationError, setOperationError] = React.useState<string | null>(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand All @@ -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 {
Expand All @@ -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
)
Expand All @@ -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);
},
() => {
Expand Down Expand Up @@ -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 ? (
<AlertActionLink onClick={handleRetry} isDisabled={isSending}>
Retry
</AlertActionLink>
) : undefined;

return (
<Message
key={message.id}
Expand All @@ -512,18 +551,31 @@ function Chat(): React.ReactElement {
}
: undefined
}
>
{canRetry && (
<Button
variant="link"
icon={<RedoIcon />}
onClick={handleRetry}
isDisabled={isSending}
>
Retry
</Button>
)}
</Message>
{...(hasError && !hasPartialContent
? {
error: {
title: errorText,
variant: 'danger',
actionLinks: retryLink,
},
}
: {})}
{...(hasError && hasPartialContent
? {
extraContent: {
afterMainContent: (
<Alert
variant="danger"
title={errorText}
isInline
isPlain
actionLinks={retryLink}
/>
),
},
}
: {})}
/>
);
})}
</MessageBox>
Expand Down
Loading