Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: ActiveSources + type Sources #82

Merged
merged 4 commits into from
Mar 5, 2025
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
4 changes: 2 additions & 2 deletions lexio/lib/components/LexioProvider/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react';
import { Provider, createStore } from 'jotai';
import { configAtom, registeredActionHandlersAtom } from '../../state/rag-state';
import { ActionHandler, RAGConfig } from '../../types';
import { ActionHandler, ProviderConfig } from '../../types';


import { ThemeProvider } from '../../theme/ThemeContext';
Expand All @@ -12,7 +12,7 @@ interface LexioProviderProps {
children: React.ReactNode;
onAction?: ActionHandler['handler'];
theme?: Theme;
config?: RAGConfig;
config?: ProviderConfig;
}


Expand Down
111 changes: 105 additions & 6 deletions lexio/lib/components/SourcesDisplay/SourcesDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '@testing-library/jest-dom';

import { SourcesDisplay } from './SourcesDisplay';
import { LexioProvider } from '../LexioProvider';
import { UUID } from '../../types';

/**
* Utility to create a wrapper that provides:
Expand All @@ -33,8 +34,8 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
return {
// The store will update with these sources after the promise resolves
sources: Promise.resolve([
{ id: 'source1-id', title: 'Mock Source 1', type: 'text' },
{ id: 'source2-id', title: 'Mock Source 2', type: 'text' },
{ id: '123e4567-e89b-12d3-a456-426614174000' as UUID, title: 'Mock Source 1', type: 'text' },
{ id: '123e4567-e89b-12d3-a456-426614174001' as UUID, title: 'Mock Source 2', type: 'text' },
]),
};
}
Expand All @@ -60,7 +61,7 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
expect.objectContaining({ type: 'SEARCH_SOURCES', query: 'some query' }),
expect.any(Array), // messages
expect.any(Array), // sources
expect.any(Array), // activeSources
null, // activeSources can now be null
null // selectedSource on first usage is typically null
);

Expand All @@ -76,7 +77,7 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
if (action.type === 'SEARCH_SOURCES') {
return {
sources: Promise.resolve([
{ id: 'sourceA', title: 'Source A', type: 'text' },
{ id: '123e4567-e89b-12d3-a456-426614174002' as UUID, title: 'Source A', type: 'text' },
]),
};
}
Expand All @@ -97,7 +98,7 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
expect.objectContaining({ type: 'SEARCH_SOURCES', query: 'enter query' }),
expect.any(Array),
expect.any(Array),
expect.any(Array),
null, // activeSources can now be null
null
);
});
Expand All @@ -118,10 +119,14 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
// Return a single source
return {
sources: Promise.resolve([
{ id: 'source1', title: 'A Single Source', type: 'text' },
{ id: '123e4567-e89b-12d3-a456-426614174003' as UUID, title: 'A Single Source', type: 'text' },
]),
};
}
// Handle CLEAR_SOURCES action
if (action.type === 'CLEAR_SOURCES') {
return {}; // Return empty object for successful action
}
return undefined;
});

Expand Down Expand Up @@ -154,4 +159,98 @@ describe('SourcesDisplay (with onAction-based SEARCH_SOURCES)', () => {
expect(screen.getByText('No sources available')).toBeInTheDocument();
});
});

it('handles setting active sources to null', async () => {
const mockOnAction = vi.fn((action) => {
if (action.type === 'SEARCH_SOURCES') {
return {
sources: Promise.resolve([
{ id: '123e4567-e89b-12d3-a456-426614174004' as UUID, title: 'Source X', type: 'text' },
{ id: '123e4567-e89b-12d3-a456-426614174005' as UUID, title: 'Source Y', type: 'text' },
]),
};
}
if (action.type === 'SET_ACTIVE_SOURCES') {
// Return empty object for successful action
return {};
}
return undefined;
});

const wrapper = createWrapper(mockOnAction);
render(<SourcesDisplay />, { wrapper });

// Perform a search to get sources
const searchInput = screen.getByPlaceholderText('Search knowledge base...');
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'test query' } });
fireEvent.click(screen.getByText('Search'));
});

// Wait for sources to appear
await waitFor(() => {
expect(screen.getByText('Source X')).toBeInTheDocument();
expect(screen.getByText('Source Y')).toBeInTheDocument();
});

// Verify that the sources are displayed correctly
expect(screen.getByText('Source X')).toBeInTheDocument();
expect(screen.getByText('Source Y')).toBeInTheDocument();
});

it('handles setting selected source to null', async () => {
const mockOnAction = vi.fn((action) => {
if (action.type === 'SEARCH_SOURCES') {
return {
sources: Promise.resolve([
{ id: '123e4567-e89b-12d3-a456-426614174006' as UUID, title: 'Source Z', type: 'text' },
]),
};
}
if (action.type === 'SET_SELECTED_SOURCE') {
// Verify that null can be passed as sourceId
if (action.sourceId === null || action.sourceId === '') {
return {
selectedSourceId: null
};
}
return {
selectedSourceId: action.sourceId
};
}
return undefined;
});

const wrapper = createWrapper(mockOnAction);
render(<SourcesDisplay />, { wrapper });

// Perform a search to get sources
const searchInput = screen.getByPlaceholderText('Search knowledge base...');
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'test query' } });
fireEvent.click(screen.getByText('Search'));
});

// Wait for source to appear
await waitFor(() => {
expect(screen.getByText('Source Z')).toBeInTheDocument();
});

// Click on the source to select it
await act(async () => {
fireEvent.click(screen.getByText('Source Z'));
});

// Verify that SET_SELECTED_SOURCE was called with the correct sourceId
expect(mockOnAction).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SET_SELECTED_SOURCE',
sourceId: '123e4567-e89b-12d3-a456-426614174006'
}),
expect.any(Array),
expect.any(Array),
null,
null
);
});
});
119 changes: 84 additions & 35 deletions lexio/lib/components/SourcesDisplay/SourcesDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ResetWrapper } from "../../utils/ResetWrapper";
import { useSources} from "../../hooks";
import { TrashIcon } from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {addOpacity} from "../../utils/scaleFontSize.tsx";
import {addOpacity, scaleFontSize} from "../../utils/scaleFontSize.tsx";

export interface SourcesDisplayStyles extends React.CSSProperties {
backgroundColor?: string;
Expand Down Expand Up @@ -202,40 +202,69 @@ const SourcesDisplay: React.FC<SourcesDisplayProps> = ({
style={{
backgroundColor: source.id === selectedSourceId
? style.selectedSourceBackground
: activeSources.includes(source)
: activeSources && activeSources.includes(source)
? style.activeSourceBackground
: activeSources.length > 0
: activeSources && activeSources.length > 0
? style.inactiveSourceBackground
: style.inactiveSourceBackground,
borderColor: source.id === selectedSourceId
? style.selectedSourceBorderColor
: activeSources.includes(source)
: activeSources && activeSources.includes(source)
? style.selectedSourceBorderColor
: style.inactiveSourceBorderColor,
opacity: activeSources.length > 0 && !activeSources.includes(source) ? 0.6 : 1,
opacity: activeSources && activeSources.length > 0 && !activeSources.includes(source) ? 0.6 : 1,
borderRadius: style.borderRadius,
fontSize: style.fontSize,
}}
onClick={() => setSelectedSource(source.id)}
>
<div className="flex items-start justify-between">
<div className="overflow-hidden">
<p className="font-medium truncate" style={{ color: style.color }}>
{source.title}
</p>
{source.type && (
<span className="inline-block px-2 py-1 font-medium rounded-full mt-1" style={{
backgroundColor: style.sourceTypeBackground,
color: style.sourceTypeColor,
fontSize: `calc(${style.fontSize} * 0.75)` // todo: replace with utils func
<div className="overflow-hidden flex-1 mr-2">
<div className="flex items-center gap-2">
<p className="font-medium truncate" style={{
color: style.color,
fontSize: scaleFontSize(style.fontSize || '16px', 1.0)
}}>
{source.type}
</span>
{source.title}
</p>
{source.href && (
<a
href={source.href}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 p-1 rounded hover:bg-gray-100 transition-colors"
onClick={(e) => e.stopPropagation()}
title="Open source link"
style={{
color: style.buttonBackground,
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
{source.description && (
<p className="text-gray-500 line-clamp-2" style={{
color: addOpacity(style.color || colors.text, 0.6),
fontSize: scaleFontSize(style.fontSize || '14px', 0.95)
}}>
{source.description}
</p>
)}
{showRelevanceScore && source.relevance !== undefined && (
<div className="mt-2 flex items-center">
<span style={{ color: addOpacity(style.color || colors.text, 0.6), fontSize: `calc(${style.fontSize} * 0.9)` }}>Relevance:</span>
<div className="ml-2 h-2 w-24 rounded-full" style={{ backgroundColor: style.metadataTagBackground }}>
<div className="mt-2 flex items-center group relative">
<span style={{
color: addOpacity(style.color || colors.text, 0.6),
fontSize: scaleFontSize(style.fontSize || '12px', 0.85),
}}>Relevance:</span>
<div
className="ml-2 h-2 w-24 rounded-full"
style={{
backgroundColor: style.metadataTagBackground,
}}
>
<div
className="h-2 rounded-full"
style={{
Expand All @@ -244,29 +273,49 @@ const SourcesDisplay: React.FC<SourcesDisplayProps> = ({
}}
/>
</div>
<div className="absolute bottom-full left-1/3 transform -translate-x-1 mb-1 px-2 py-1 rounded text-xs whitespace-nowrap opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-opacity duration-200"
style={{
backgroundColor: style.backgroundColor,
color: style.color,
border: `1px solid ${style.metadataTagBackground}`,
fontSize: scaleFontSize(style.fontSize || '12px', 0.75),
}}>
{Math.round(source.relevance * 100)}%
</div>
</div>
)}
</div>
{source.type && (
<span className="inline-block px-2 py-1 font-medium rounded-full flex-shrink-0" style={{
backgroundColor: style.sourceTypeBackground,
color: style.sourceTypeColor,
fontSize: scaleFontSize(style.fontSize || '12px', 0.8)
}}>
{source.type}
</span>
)}
</div>
{showMetadata && source.metadata && Object.keys(source.metadata).length > 0 && (
<div className="mt-2 pt-2 border-t" style={{ borderColor: style.inactiveSourceBorderColor }}>
<div className="pt-2 border-t" style={{ borderColor: style.inactiveSourceBorderColor }}>
<div className="flex flex-wrap gap-2">
{Object.entries(source.metadata).map(([key, value]) => (
<span
key={key}
className="inline-flex items-center px-2 py-1 rounded-md"
style={{
backgroundColor: style.metadataTagBackground,
color: style.metadataTagColor,
fontSize: `calc(${style.fontSize} * 0.75)`,
lineHeight: '1.2',
}}
>
{key}: {value}
</span>
))}
{Object.entries(source.metadata)
.filter(([key]) => typeof key === "string" && !key.startsWith("_"))
.map(([key, value]) => (
<span
key={key}
className="inline-flex items-center px-2 py-1 rounded-md"
style={{
backgroundColor: style.metadataTagBackground,
color: style.metadataTagColor,
fontSize: scaleFontSize(style.fontSize || '12px', 0.85),
lineHeight: '1.2',
}}
>
{key}: {value}
</span>
))}
</div>
</div>
</div>
)}
</li>
))}
Expand Down
12 changes: 6 additions & 6 deletions lexio/lib/hooks/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ export const useSources = (component: Component) => {
dispatch({type: 'CLEAR_SOURCES', source: component}, false);
};

const setActiveSources = (sourceIds: string[] | UUID[]) => {
dispatch({type: 'SET_ACTIVE_SOURCES', sourceIds, source: component}, false);
const setActiveSources = (sourceIds: string[] | UUID[] | null) => {
dispatch({type: 'SET_ACTIVE_SOURCES', sourceIds: sourceIds || [], source: component}, false);
};

const setSelectedSource = (sourceId: string | UUID) => {
dispatch({type: 'SET_SELECTED_SOURCE', sourceId, source: component}, false);
const setSelectedSource = (sourceId: string | UUID | null) => {
dispatch({type: 'SET_SELECTED_SOURCE', sourceId: sourceId || '', source: component}, false);
};

const setFilterSources = (filter: any) => {
Expand Down Expand Up @@ -79,8 +79,8 @@ export const useMessages = (component: Component) => {
* Set the active message in the chat window
* @param messageId
*/
const setActiveMessage = (messageId: string | UUID) => {
dispatch({type: 'SET_ACTIVE_MESSAGE', messageId, source: component}, false);
const setActiveMessage = (messageId: string | UUID | null) => {
dispatch({type: 'SET_ACTIVE_MESSAGE', messageId: messageId || '', source: component}, false);
};

const clearMessages = () => {
Expand Down
Loading
Loading