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
61 changes: 61 additions & 0 deletions src/components/ai-chat/__tests__/chat-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { View, ViewLayout } from '@/application/types';
import { buildInitialAIChatSettings, isWorkspaceRootView } from '@/components/ai-chat/chat-settings';

function view(overrides: Partial<View>): View {
return {
view_id: 'view-id',
name: 'View',
icon: null,
layout: ViewLayout.Document,
extra: { is_space: false },
children: [],
is_published: false,
is_private: false,
...overrides,
};
}

describe('AI chat initial settings', () => {
it('uses full workspace context for chats created under a workspace root', () => {
const parent = view({
view_id: 'space-id',
extra: { is_space: true },
});

expect(isWorkspaceRootView(parent)).toBe(true);
expect(
buildInitialAIChatSettings({
parent,
query: 'appflowy',
sourceIds: ['doc-1', 'doc-2'],
})
).toEqual({
full_workspace: true,
rag_ids: [],
metadata: { initial_prompt: 'appflowy' },
});
});

it('keeps explicit source context for chats created under a page', () => {
expect(
buildInitialAIChatSettings({
parent: view({ view_id: 'page-id' }),
sourceIds: ['doc-1', 'doc-1', '', 'doc-2'],
})
).toEqual({
full_workspace: false,
rag_ids: ['doc-1', 'doc-2'],
});
});

it('preserves the initial prompt without adding source context for non-root chats', () => {
expect(
buildInitialAIChatSettings({
parent: view({ view_id: 'page-id' }),
query: 'summarize this',
})
).toEqual({
metadata: { initial_prompt: 'summarize this' },
});
});
});
33 changes: 33 additions & 0 deletions src/components/ai-chat/chat-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { View } from '@/application/types';
import type { ChatSettings } from '@/components/chat/types';

export function isWorkspaceRootView(view: View | undefined): boolean {
return Boolean(view?.extra?.is_space);
}

export function buildInitialAIChatSettings({
parent,
query,
sourceIds,
}: {
parent?: View;
query?: string;
sourceIds?: string[];
}): Partial<Pick<ChatSettings, 'rag_ids' | 'metadata' | 'full_workspace'>> {
const metadata = query ? { initial_prompt: query } : undefined;

if (isWorkspaceRootView(parent)) {
return {
full_workspace: true,
rag_ids: [],
...(metadata ? { metadata } : {}),
};
}

const uniqueSourceIds = Array.from(new Set(sourceIds || [])).filter(Boolean);

return {
...(uniqueSourceIds.length > 0 ? { full_workspace: false, rag_ids: uniqueSourceIds } : {}),
...(metadata ? { metadata } : {}),
};
}
21 changes: 11 additions & 10 deletions src/components/app/search/BestMatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,17 @@ function BestMatch({
onClose={onClose}
onLoadMore={handleLoadMore}
header={
<SearchAIOverview
aiEnabled={aiEnabled}
askingAI={askingAI}
loading={loading || summaryLoading}
query={searchValue}
sources={overviewSources}
summary={summary}
onClose={onClose}
onAskAI={(sourceIds) => onAskAI(searchValue, sourceIds)}
/>
aiEnabled ? (
<SearchAIOverview
askingAI={askingAI}
loading={loading || summaryLoading}
query={searchValue}
sources={overviewSources}
summary={summary}
onClose={onClose}
onAskAI={(sourceIds) => onAskAI(searchValue, sourceIds)}
/>
) : undefined
}
/>
);
Expand Down
10 changes: 4 additions & 6 deletions src/components/app/search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg';
import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg';
import { notify } from '@/components/_shared/notify';
import { findAncestors } from '@/components/_shared/outline/utils';
import { buildInitialAIChatSettings } from '@/components/ai-chat/chat-settings';
import {
useAIEnabled,
useAppOperations,
Expand Down Expand Up @@ -71,10 +72,10 @@ export function Search() {
name: query || t('chat.newChat', { defaultValue: 'New chat' }),
prev_view_id: parent.children?.[parent.children.length - 1]?.view_id,
});
const uniqueSourceIds = Array.from(new Set(sourceIds || [])).filter(Boolean);
const initialSettings = buildInitialAIChatSettings({ parent, query, sourceIds });
let settingsError: unknown;

if (uniqueSourceIds.length > 0 || query) {
if (Object.keys(initialSettings).length > 0) {
try {
const [{ ChatRequest }, { getAxiosInstance }] = await Promise.all([
import('@/components/chat/request'),
Expand All @@ -88,10 +89,7 @@ export function Search() {

const request = new ChatRequest(currentWorkspaceId, created.view_id, axiosInstance);

await request.updateChatSettings({
...(uniqueSourceIds.length > 0 ? { rag_ids: uniqueSourceIds } : {}),
...(query ? { metadata: { initial_prompt: query } } : {}),
});
await request.updateChatSettings(initialSettings);
} catch (error) {
settingsError = error;
}
Expand Down
4 changes: 0 additions & 4 deletions src/components/app/search/SearchAIOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export interface SearchOverviewSource {
}

interface SearchAIOverviewProps {
aiEnabled: boolean;
askingAI: boolean;
loading: boolean;
query: string;
Expand Down Expand Up @@ -202,7 +201,6 @@ function AskAIButton({ askingAI, query, onAskAI }: { askingAI: boolean; query: s
}

export function SearchAIOverview({
aiEnabled,
askingAI,
loading,
query,
Expand All @@ -213,8 +211,6 @@ export function SearchAIOverview({
}: SearchAIOverviewProps) {
const { t } = useTranslation();

if (!aiEnabled) return null;

if (loading) {
return (
<div className='px-2 py-1'>
Expand Down
75 changes: 75 additions & 0 deletions src/components/app/search/__tests__/BestMatch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, beforeEach, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';

import BestMatch from '../BestMatch';

import type { ReactNode } from 'react';

const mockUseAIEnabled = jest.fn();
const mockSearchWorkspaceDocumentPage = jest.fn();
const mockGenerateSearchSummary = jest.fn();
const mockAppOutline: never[] = [];
const mockT = (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key;

jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT,
}),
}));

jest.mock('@/components/app/app.hooks', () => ({
useAIEnabled: () => mockUseAIEnabled(),
useAppOutline: () => mockAppOutline,
useCurrentWorkspaceId: () => 'workspace-id',
}));

jest.mock('@/application/services/domains', () => ({
SearchService: {
searchWorkspaceDocumentPage: (...args: unknown[]) => mockSearchWorkspaceDocumentPage(...args),
generateSearchSummary: (...args: unknown[]) => mockGenerateSearchSummary(...args),
},
}));

jest.mock('@/components/app/search/SearchAIOverview', () => ({
SearchAIOverview: () => <div data-testid='ai-overview' />,
}));

jest.mock('@/components/app/search/ViewList', () => ({
__esModule: true,
default: ({ header }: { header?: ReactNode }) => (
<div data-testid='view-list'>{header ? <div data-testid='search-header'>{header}</div> : null}</div>
),
}));

function renderBestMatch() {
return render(<BestMatch askingAI={false} searchValue='' onAskAI={jest.fn()} onClose={jest.fn()} />);
}

describe('BestMatch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAIEnabled.mockReturnValue(true);
mockSearchWorkspaceDocumentPage.mockResolvedValue({
has_more: false,
items: [],
next_offset: null,
});
mockGenerateSearchSummary.mockResolvedValue({ summaries: [] });
});

it('does not mount the AI overview header when server info disables AI', () => {
mockUseAIEnabled.mockReturnValue(false);

renderBestMatch();

expect(screen.queryByTestId('search-header')).toBeNull();
expect(screen.queryByTestId('ai-overview')).toBeNull();
});

it('mounts the AI overview header when AI is enabled', () => {
renderBestMatch();

expect(screen.getByTestId('search-header')).toBeTruthy();
expect(screen.getByTestId('ai-overview')).toBeTruthy();
});
});
69 changes: 55 additions & 14 deletions src/components/app/view-actions/AddPageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { toast } from 'sonner';
import { View, ViewLayout } from '@/application/types';
import { ReactComponent as UploadIcon } from '@/assets/icons/upload.svg';
import { ViewIcon } from '@/components/_shared/view-icon';
import { useAIEnabled, useAppOperations, useOpenPageModal, useToView } from '@/components/app/app.hooks';
import { buildInitialAIChatSettings } from '@/components/ai-chat/chat-settings';
import {
useAIEnabled,
useAppOperations,
useCurrentWorkspaceId,
useOpenPageModal,
useToView,
} from '@/components/app/app.hooks';
import { DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';

Expand All @@ -15,31 +22,63 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: (
const openPageModal = useOpenPageModal();
const toView = useToView();
const aiEnabled = useAIEnabled();
const currentWorkspaceId = useCurrentWorkspaceId();
const lastChildViewId = view.children?.[view.children.length - 1]?.view_id;

const handleAddPage = useCallback(
async (layout: ViewLayout, name?: string) => {
if (!addPage) return;
if (layout === ViewLayout.AIChat && !aiEnabled) return;
toast.loading(t('document.creating'));

const loadingToastId = toast.loading(t('document.creating'));

try {
// Append after the last child so the new page appears at the bottom.
// When prev_view_id is omitted the backend prepends (inserts at index 0).
const response = await addPage(view.view_id, { layout, name, prev_view_id: lastChildViewId });

if (layout === ViewLayout.AIChat && currentWorkspaceId) {
const initialSettings = buildInitialAIChatSettings({ parent: view });

if (Object.keys(initialSettings).length > 0) {
try {
const [{ ChatRequest }, { getAxiosInstance }] = await Promise.all([
import('@/components/chat/request'),
import('@/application/services/js-services/http'),
]);
const axiosInstance = getAxiosInstance();

if (!axiosInstance) {
throw new Error('Missing axios instance');
}

const request = new ChatRequest(currentWorkspaceId, response.view_id, axiosInstance);

await request.updateChatSettings(initialSettings);
} catch {
toast.error(
t('search.updateAIChatSettingsFailed', {
defaultValue: 'AI chat was created, but the context could not be attached',
})
);
}
}
}

if (layout === ViewLayout.Document) {
void openPageModal?.(response.view_id);
} else {
void toView(response.view_id);
}

toast.dismiss();
toast.dismiss(loadingToastId);
// eslint-disable-next-line
} catch (e: any) {
toast.dismiss(loadingToastId);
toast.error(e.message);
}
},
[addPage, aiEnabled, openPageModal, t, toView, view.view_id, lastChildViewId]
[addPage, aiEnabled, currentWorkspaceId, openPageModal, t, toView, view, lastChildViewId]
);

const actions: {
Expand Down Expand Up @@ -80,16 +119,18 @@ function AddPageActions({ view, onImportClick }: { view: View; onImportClick?: (
void handleAddPage(ViewLayout.Calendar, t('document.plugins.database.newDatabase'));
},
},
...(aiEnabled ? [
{
label: t('chat.newChat'),
icon: <ViewIcon layout={ViewLayout.AIChat} size={'small'} />,
testId: 'add-ai-chat-button',
onSelect: () => {
void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName'));
},
},
] : []),
...(aiEnabled
? [
{
label: t('chat.newChat'),
icon: <ViewIcon layout={ViewLayout.AIChat} size={'small'} />,
testId: 'add-ai-chat-button',
onSelect: () => {
void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName'));
},
},
]
: []),
{
label: t('chart.menuName'),
icon: <ViewIcon layout={ViewLayout.Chart} size={'small'} />,
Expand Down
Loading
Loading