diff --git a/src/components/ai-chat/__tests__/chat-settings.test.ts b/src/components/ai-chat/__tests__/chat-settings.test.ts new file mode 100644 index 000000000..98b30331a --- /dev/null +++ b/src/components/ai-chat/__tests__/chat-settings.test.ts @@ -0,0 +1,61 @@ +import { View, ViewLayout } from '@/application/types'; +import { buildInitialAIChatSettings, isWorkspaceRootView } from '@/components/ai-chat/chat-settings'; + +function view(overrides: Partial): 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' }, + }); + }); +}); diff --git a/src/components/ai-chat/chat-settings.ts b/src/components/ai-chat/chat-settings.ts new file mode 100644 index 000000000..ee2ffebf5 --- /dev/null +++ b/src/components/ai-chat/chat-settings.ts @@ -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> { + 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 } : {}), + }; +} diff --git a/src/components/app/search/BestMatch.tsx b/src/components/app/search/BestMatch.tsx index 1eb6fd24d..805cfa63d 100644 --- a/src/components/app/search/BestMatch.tsx +++ b/src/components/app/search/BestMatch.tsx @@ -298,16 +298,17 @@ function BestMatch({ onClose={onClose} onLoadMore={handleLoadMore} header={ - onAskAI(searchValue, sourceIds)} - /> + aiEnabled ? ( + onAskAI(searchValue, sourceIds)} + /> + ) : undefined } /> ); diff --git a/src/components/app/search/Search.tsx b/src/components/app/search/Search.tsx index 2434948ee..faff6c027 100644 --- a/src/components/app/search/Search.tsx +++ b/src/components/app/search/Search.tsx @@ -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, @@ -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'), @@ -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; } diff --git a/src/components/app/search/SearchAIOverview.tsx b/src/components/app/search/SearchAIOverview.tsx index 8a0369220..5ce196ccd 100644 --- a/src/components/app/search/SearchAIOverview.tsx +++ b/src/components/app/search/SearchAIOverview.tsx @@ -21,7 +21,6 @@ export interface SearchOverviewSource { } interface SearchAIOverviewProps { - aiEnabled: boolean; askingAI: boolean; loading: boolean; query: string; @@ -202,7 +201,6 @@ function AskAIButton({ askingAI, query, onAskAI }: { askingAI: boolean; query: s } export function SearchAIOverview({ - aiEnabled, askingAI, loading, query, @@ -213,8 +211,6 @@ export function SearchAIOverview({ }: SearchAIOverviewProps) { const { t } = useTranslation(); - if (!aiEnabled) return null; - if (loading) { return (
diff --git a/src/components/app/search/__tests__/BestMatch.test.tsx b/src/components/app/search/__tests__/BestMatch.test.tsx new file mode 100644 index 000000000..8094901b4 --- /dev/null +++ b/src/components/app/search/__tests__/BestMatch.test.tsx @@ -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: () =>
, +})); + +jest.mock('@/components/app/search/ViewList', () => ({ + __esModule: true, + default: ({ header }: { header?: ReactNode }) => ( +
{header ?
{header}
: null}
+ ), +})); + +function renderBestMatch() { + return render(); +} + +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(); + }); +}); diff --git a/src/components/app/view-actions/AddPageActions.tsx b/src/components/app/view-actions/AddPageActions.tsx index 7cf0f7c11..376fb1d88 100644 --- a/src/components/app/view-actions/AddPageActions.tsx +++ b/src/components/app/view-actions/AddPageActions.tsx @@ -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'; @@ -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: { @@ -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: , - testId: 'add-ai-chat-button', - onSelect: () => { - void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName')); - }, - }, - ] : []), + ...(aiEnabled + ? [ + { + label: t('chat.newChat'), + icon: , + testId: 'add-ai-chat-button', + onSelect: () => { + void handleAddPage(ViewLayout.AIChat, t('menuAppHeader.defaultNewPageName')); + }, + }, + ] + : []), { label: t('chart.menuName'), icon: , diff --git a/src/components/chat/components/chat-input/related-views/index.tsx b/src/components/chat/components/chat-input/related-views/index.tsx index 1325f1713..9949fa481 100644 --- a/src/components/chat/components/chat-input/related-views/index.tsx +++ b/src/components/chat/components/chat-input/related-views/index.tsx @@ -11,44 +11,58 @@ import { useCheckboxTree } from '@/components/chat/hooks/use-checkbox-tree'; import { MESSAGE_VARIANTS } from '@/components/chat/lib/animations'; import { searchViews } from '@/components/chat/lib/views'; import { useMessagesHandlerContext } from '@/components/chat/provider/messages-handler-provider'; -import { View } from '@/components/chat/types'; +import { View, ViewLayout } from '@/components/chat/types'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; import { Spaces } from './spaces'; +function collectSelectableViewIds(views: View[]): string[] { + const ids: string[] = []; + const stack = [...views]; + while (stack.length > 0) { + const view = stack.pop(); -export function RelatedViews() { + if (!view || view.layout === ViewLayout.AIChat) continue; + + ids.push(view.view_id); + stack.push(...view.children); + } + + return ids; +} +export function RelatedViews() { const [searchValue, setSearchValue] = useState(''); const [open, setOpen] = useState(false); const { chatSettings, updateChatSettings } = useMessagesHandlerContext(); - const viewIds = useMemo(() => { - return chatSettings?.rag_ids || []; - }, [chatSettings]); - - const { - fetchViews, - viewsLoading, - } = useViewLoader(); + const { fetchViews, viewsLoading } = useViewLoader(); const [folder, setFolder] = useState(null); useEffect(() => { - void (async() => { + void (async () => { const data = await fetchViews(); - if(!data) return; + if (!data) return; setFolder(data); })(); }, [fetchViews]); + const viewIds = useMemo(() => { + if (chatSettings?.full_workspace && folder) { + return collectSelectableViewIds(folder.children || []); + } + + return chatSettings?.rag_ids || []; + }, [chatSettings, folder]); + const filteredSpaces = useMemo(() => { - const spaces = folder?.children.filter(view => view.extra?.is_space); + const spaces = folder?.children.filter((view) => view.extra?.is_space); return searchViews(spaces || [], searchValue); }, [folder, searchValue]); @@ -57,19 +71,15 @@ export function RelatedViews() { return folder?.children || []; }, [folder]); - const { - getSelected, - getCheckStatus, - toggleNode, - getInitialExpand, - } = useCheckboxTree(viewIds, views); + const { getSelected, getCheckStatus, toggleNode, getInitialExpand } = useCheckboxTree(viewIds, views); const length = getSelected().length; const handleToggle = useMemo(() => { - return debounce(async(ids: string[]) => { + return debounce(async (ids: string[]) => { await updateChatSettings({ rag_ids: ids, + full_workspace: false, }); }, 500); }, [updateChatSettings]); @@ -86,37 +96,30 @@ export function RelatedViews() { > {length} - {viewsLoading ? : } - + {viewsLoading ? : } - + -
+
{ - const ids = toggleNode(view); + onToggle={(view: View) => { + const ids = toggleNode(view); - void handleToggle(Array.from(ids)); - } - } + void handleToggle(Array.from(ids)); + }} />
diff --git a/src/components/chat/types/request.ts b/src/components/chat/types/request.ts index f6a45f227..d6507454d 100644 --- a/src/components/chat/types/request.ts +++ b/src/components/chat/types/request.ts @@ -101,6 +101,7 @@ export interface ChatSettings { name: string; rag_ids: string[]; metadata: Record; + full_workspace?: boolean; web_search_enabled: boolean; }