Skip to content
Draft
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
46 changes: 37 additions & 9 deletions apps/frontend/src/lib/components/Chat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@
introTitle = ''
}: Props = $props();

let current_input = $state('');
let is_streaming = $state(false);
let final_answer_started = $state(false);
let messages = $state<Array<Message>>([]);
let chat_started = $state(false);
let generationError = $state<Error | null>(null);
let last_user_message = $state<string>('');
let current_input = $state('');
let is_streaming = $state(false);
let final_answer_started = $state(false);
let messages = $state<Array<Message>>([]);
let chat_started = $state(false);
let generationError = $state<Error | null>(null);
let last_user_message = $state<string>('');
let abortController = $state<AbortController | null>(null);
let wasCancelled = $state(false);

// Load existing messages from thread on component initialization
onMount(() => {
Expand All @@ -58,6 +60,11 @@
function updateMessages(chunk: Message) {
console.debug('Processing chunk in inputSubmit:', chunk);

// Apply cancellation state if we are in cancelled mode
if (wasCancelled && chunk.type === 'ai') {
chunk.isCancelled = true;
}

// Look for existing message with same id
const messageIndex = messages.findIndex((m) => m.id === chunk.id);

Expand All @@ -74,6 +81,11 @@
if (existing.type == 'tool' && 'status' in chunk) {
existing.status = chunk.status;
}

// Propagate cancellation state when updating messages
if (chunk.isCancelled !== undefined) {
existing.isCancelled = chunk.isCancelled;
}
}

if (!final_answer_started && chunk.type == 'ai' && chunk.text) final_answer_started = true;
Expand Down Expand Up @@ -117,23 +129,31 @@
is_streaming = true;
final_answer_started = false;
generationError = null; // Clear previous errors
wasCancelled = false; // Reset cancellation state
abortController = new AbortController();

try {
for await (const chunk of streamAnswer(
langGraphClient,
thread.thread_id,
assistantId,
messageText,
messageId
messageId,
abortController.signal
))
updateMessages(chunk);
} catch (err) {
if (err instanceof Error) generationError = err;
if (err instanceof Error && err.name === 'AbortError') {
console.log('Generation cancelled by user');
} else if (err instanceof Error) {
generationError = err;
}
error(500, {
message: 'Error during generation'
});
} finally {
is_streaming = false;
abortController = null;
}
}
}
Expand All @@ -144,6 +164,13 @@
submitInputOrRetry(true);
}
}

function cancelGeneration() {
if (abortController) {
abortController.abort('User cancelled generation');
wasCancelled = true;
}
}
</script>

<div class="flex h-[calc(100vh-4rem)] flex-col">
Expand All @@ -164,6 +191,7 @@
finalAnswerStarted={final_answer_started}
{generationError}
onRetryError={retryGeneration}
onCancel={cancelGeneration}
/>
{/if}
</div>
Expand Down
94 changes: 94 additions & 0 deletions apps/frontend/src/lib/components/Chat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';

describe('Chat component tests', () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
});

test('should be testable in isolation', () => {
// This is a basic test to ensure our setup works
expect(true).toBe(true);
});

test('should parse cancellation correctly', () => {
// Test that we can detect AbortError
const error = new DOMException('Aborted', 'AbortError');
expect(error.name).toBe('AbortError');
});

test('should set correct cancellation state', () => {
// Test state transition logic
let wasCancelled = false;
const cancelGeneration = () => {
wasCancelled = true;
};

expect(wasCancelled).toBe(false);
cancelGeneration();
expect(wasCancelled).toBe(true);
});

test('should handle cancelled message state correctly', () => {
// Test message state with cancellation flag
const message = {
id: 'test-1',
type: 'ai',
text: 'Hello world',
isCancelled: true
};

expect(message).toMatchObject({
isCancelled: true,
type: 'ai',
text: expect.stringContaining('Hello')
});
});

test('should handle message updates with cancellation', () => {
// Test message state transition when cancelled
let messages = [
{
id: 'user-1',
type: 'user',
text: 'Test input',
isCancelled: false
}
];

const isStreaming = true;
const wasCancelled = false;

// Simulate updated message with cancellation
messages.push({
id: 'ai-1',
type: 'ai',
text: 'Partial response',
isCancelled: true
});

expect(messages).toHaveLength(2);
expect(messages[1]).toMatchObject({
id: 'ai-1',
type: 'ai',
isCancelled: true
});
});

test('should generate correct error signature for AbortError', () => {
// Test proper AbortError format
const abortError = {
name: 'AbortError',
message: 'The operation was aborted'
};

function isAbortError(error: any): boolean {
return error && error.name === 'AbortError';
}

expect(isAbortError(abortError)).toBe(true);
expect(isAbortError({})).toBe(false);
expect(isAbortError({ name: 'Error' })).toBe(false);
});
});
11 changes: 9 additions & 2 deletions apps/frontend/src/lib/components/ChatMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,26 @@
>
{#if message.type === 'ai'}
<Card
class="w-full max-w-none border border-gray-200 bg-gray-50 p-4 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-800"
class="w-full max-w-none border border-gray-200 bg-gray-50 p-4 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-800 {message.isCancelled ? 'opacity-70' : ''}"
title={message.isCancelled ? 'Generation interrupted' : ''}
>
<div class="prose prose-gray dark:prose-invert max-w-none leading-relaxed">
<Markdown md={message.text} {plugins} />
{#if message.isCancelled}
<span class="text-muted-foreground ml-1" aria-label="(incomplete)">…</span>
{/if}
</div>
</Card>
<AIMessageActions {message} {isHovered} {onRegenerate} {onFeedback} />
{:else}
<Card
class="w-full max-w-none border-0 bg-gray-800 p-4 text-sm shadow-sm dark:bg-gray-700"
class="w-full max-w-none border-0 bg-gray-800 p-4 text-sm shadow-sm dark:bg-gray-700 {message.isCancelled ? 'opacity-70' : ''}"
>
<div class="prose prose-invert max-w-none leading-relaxed whitespace-pre-wrap">
{message.text}
{#if message.isCancelled}
<span class="text-muted-foreground ml-1" aria-label="(incomplete)">…</span>
{/if}
</div>
</Card>
<UserMessageActions {message} {isHovered} {onEdit} />
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/lib/components/ChatMessages.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
finalAnswerStarted: boolean;
generationError?: Error | null;
onRetryError?: () => void;
onCancel?: () => void;
}

let { messages = [], finalAnswerStarted, generationError = null, onRetryError }: Props = $props();
let { messages = [], finalAnswerStarted, generationError = null, onRetryError, onCancel }: Props = $props();
</script>

<ScrollableContainer>
Expand All @@ -32,7 +33,7 @@
{#if generationError && onRetryError}
<ChatErrorMessage error={generationError} onRetry={onRetryError} />
{:else if !finalAnswerStarted}
<ChatWaiting />
<ChatWaiting onCancel={onCancel} />
{/if}
</div>
{/snippet}
Expand Down
19 changes: 18 additions & 1 deletion apps/frontend/src/lib/components/ChatWaiting.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<script lang="ts">
import { UserOutline } from 'flowbite-svelte-icons';
import { Spinner } from 'flowbite-svelte';

interface Props {
onCancel?: () => void;
}

let { onCancel }: Props = $props();
</script>

<div class="mb-6 flex w-full justify-start">
Expand All @@ -11,7 +17,18 @@
<UserOutline size="sm" class="text-white dark:text-gray-900" />
</div>
<div class="relative w-full">
<Spinner />
<div class="flex items-center gap-3">
<Spinner />
{#if onCancel}
<button
onclick={() => onCancel()}
title="Stop generation"
class="px-2 py-1 text-xs rounded-md border border-border hover:bg-muted/50 transition-colors"
>
Stop
</button>
{/if}
</div>
</div>
</div>
</div>
85 changes: 85 additions & 0 deletions apps/frontend/src/lib/components/ChatWaiting.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, test, expect, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render } from '@testing-library/svelte';
import ChatWaiting from './ChatWaiting.svelte';

describe('ChatWaiting component', () => {
test('renders spinner without cancel button when no onCancel provided', () => {
const { container } = render(ChatWaiting, {
props: {}
});

// Should have user avatar
const userAvatar = container.querySelector('.flex.w-8.h-8');
expect(userAvatar).toBeInTheDocument();

// Should have spinner - check for any loading indicator element
const spinner = container.querySelector('svg') || container.querySelector('[role="status"]') || container.querySelector('.animate-spin');
expect(spinner).toBeTruthy();

// Should not have cancel button
const cancelButton = container.querySelector('button');
expect(cancelButton).not.toBeInTheDocument();
});

test('renders cancel button when onCancel provided', () => {
const mockCancel = vi.fn();
const { container } = render(ChatWaiting, {
props: {
onCancel: mockCancel
}
});

// Should have cancel button
const cancelButton = container.querySelector('button');
expect(cancelButton).toBeInTheDocument();
expect(cancelButton).toHaveTextContent('Stop');
expect(cancelButton).toHaveClass('px-2', 'py-1', 'text-xs', 'rounded-md', 'border', 'border-border');
expect(cancelButton).toHaveAttribute('title', 'Stop generation');
});

test('cancel button triggers callback', async () => {
const mockCancel = vi.fn();
const { container } = render(ChatWaiting, {
props: {
onCancel: mockCancel
}
});

const cancelButton = container.querySelector('button');
cancelButton?.click();

expect(mockCancel).toHaveBeenCalledTimes(1);
});

test('renders with correct structure', () => {
const { container } = render(ChatWaiting, {
props: {
onCancel: vi.fn()
}
});

// Should have the main container structure
const mainDiv = container.querySelector('.mb-6.flex.w-full.justify-start');
expect(mainDiv).toBeInTheDocument();

// Should have gap-3 spacing
const flexContainer = container.querySelector('.flex.gap-3');
expect(flexContainer).toBeInTheDocument();

// Should have max-width constraint
expect(flexContainer).toHaveClass('max-w-[80%]');
});

test('propagates onCancel function correctly', () => {
const mockCancel = vi.fn();
const { component } = render(ChatWaiting, {
props: {
onCancel: mockCancel
}
});

// The component receives the prop correctly
expect(component).toBeTruthy();
});
});
6 changes: 4 additions & 2 deletions apps/frontend/src/lib/langgraph/streamAnswer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export async function* streamAnswer(
threadId: string,
assistantId: string,
input: string,
messageId: string
messageId: string,
signal?: AbortSignal
): AsyncGenerator<Message, void, unknown> {
const input_message: HumanMessage = { type: 'human', content: input, id: messageId };

Expand All @@ -18,7 +19,8 @@ export async function* streamAnswer(
input: {
messages: [input_message]
},
streamMode: 'messages-tuple'
streamMode: 'messages-tuple',
signal
});

for await (const chunk of streamResponse) {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/lib/langgraph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface BaseMessage {
type: string;
text: string;
id: string;
isCancelled?: boolean;
}

export interface AIMessage extends BaseMessage {
Expand Down
Loading