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
2 changes: 1 addition & 1 deletion packages/apollo-react/src/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ The Material renderer depends on this folder, never the other way round.

## Wind renderer notes

- The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`), so it themes itself regardless of the host's body class. Menus and tooltips portal inside that root, or into a themed node inside `portalContainer` when the host passes one.
- The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`, `future-light`, `future-dark`), so it themes itself regardless of the host's body class. Menus, tooltips and hover cards portal inside that root, or into a themed node inside `portalContainer` when the host passes one. Modal dialogs (code block expand and HTML preview) always portal to that themed node, placed in `portalContainer` or else `document.body` at z-index 1300 (Material's modal level), so they cover the page rather than the chat panel.
- `spacing.compactMode` sets `data-density="compact"` on the root.
- `icon` values on custom header actions and message actions are renderer-specific: Material takes Material Icons names, Wind takes lucide names (kebab-case such as `file-text`, or the PascalCase export name `FileText`). Wind loads each icon lazily through `lucide-react/dynamic` and shows a neutral box for names it cannot resolve.

Expand Down
155 changes: 155 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-citations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { act, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
type AutopilotChatMessage,
AutopilotChatPreHookAction,
AutopilotChatService,
} from '../../service';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import { useMessageSources, useOpenCitation } from './use-citations';

let instanceCounter = 0;

const createService = () =>
AutopilotChatService.Instantiate({ instanceName: `citations-${++instanceCounter}` });

const wrap = (service: AutopilotChatService, children: React.ReactNode) => (
<AutopilotChatServiceProvider chatServiceInstance={service}>
{children}
</AutopilotChatServiceProvider>
);

describe('useOpenCitation', () => {
afterEach(() => vi.restoreAllMocks());

it('asks the citation-click pre-hook, then opens the citation unless it declines', async () => {
const service = createService();
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const hook = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false);
service.setPreHook(AutopilotChatPreHookAction.CitationClick, hook);
let openCitation: ReturnType<typeof useOpenCitation> | undefined;
const Probe = () => {
openCitation = useOpenCitation();
return null;
};
render(wrap(service, <Probe />));
const pdf = { id: 2, title: 'Guide', download_url: 'https://a.test/g.pdf', page_number: 3 };

await act(async () => openCitation?.(pdf));
await act(async () => openCitation?.(pdf));

expect(hook).toHaveBeenCalledWith({ citation: pdf });
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith(
'https://a.test/g.pdf#page=3',
'_blank',
'noopener,noreferrer'
);
});
});

describe('useOpenCitation with unsafe URLs', () => {
afterEach(() => vi.restoreAllMocks());

it('still asks the pre-hook with the unchanged citation but never opens a javascript: URL', async () => {
const service = createService();
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const hook = vi.fn().mockResolvedValue(true);
service.setPreHook(AutopilotChatPreHookAction.CitationClick, hook);
let openCitation: ReturnType<typeof useOpenCitation> | undefined;
const Probe = () => {
openCitation = useOpenCitation();
return null;
};
render(wrap(service, <Probe />));
const unsafe = { id: 1, title: 'Bad', url: 'javascript:alert(document.domain)' };

await act(async () => openCitation?.(unsafe));

expect(hook).toHaveBeenCalledWith({ citation: unsafe });
expect(open).not.toHaveBeenCalled();
});

it('opens an http URL and a relative PDF at its page', async () => {
const service = createService();
const open = vi.spyOn(window, 'open').mockReturnValue(null);
let openCitation: ReturnType<typeof useOpenCitation> | undefined;
const Probe = () => {
openCitation = useOpenCitation();
return null;
};
render(wrap(service, <Probe />));

await act(async () => openCitation?.({ id: 1, title: 'Web', url: 'http://a.test/page' }));
await act(async () =>
openCitation?.({ id: 2, title: 'Guide', download_url: '/files/g.pdf', page_number: 4 })
);

expect(open.mock.calls).toEqual([
['http://a.test/page', '_blank', 'noopener,noreferrer'],
['/files/g.pdf#page=4', '_blank', 'noopener,noreferrer'],
]);
});
});

describe('useMessageSources', () => {
it('follows the group as streamed content parts add citations', () => {
const service = createService();
service.sendRequest({ content: 'q' });
service.sendResponse({
id: 's1',
stream: true,
widget: 'apollo-markdown-renderer',
created_at: '',
contentPartChunk: { index: 0, text: 'A' },
});
const message = service.getConversation().at(-1) as AutopilotChatMessage;
let sources: ReturnType<typeof useMessageSources> | undefined;
const Probe = () => {
sources = useMessageSources(message);
return null;
};
render(wrap(service, <Probe />));
expect(sources?.sources).toEqual([]);

act(() =>
service.sendResponse({
id: 's1',
stream: true,
done: true,
widget: 'apollo-markdown-renderer',
created_at: '',
contentPartChunk: {
index: 0,
text: '',
citation: { id: 1, title: 'Web', url: 'https://a.test' },
},
})
);

expect(sources?.sources).toEqual([{ id: 1, title: 'Web', url: 'https://a.test' }]);
});

it('waits for the rest of the group when the reply itself says more is coming', () => {
const service = createService();
service.sendRequest({ content: 'q' });
const cited = {
widget: 'apollo-markdown-renderer',
created_at: '',
contentParts: [{ text: 'A', citations: [{ id: 1, title: 'Web', url: 'https://a.test' }] }],
};
service.sendResponse({ ...cited, id: 'w1', shouldWaitForMoreMessages: true });
const message = service.getConversation().at(-1) as AutopilotChatMessage;
let sources: ReturnType<typeof useMessageSources> | undefined;
const Probe = () => {
sources = useMessageSources(message);
return null;
};
render(wrap(service, <Probe />));
expect(sources?.isWaitingForMoreMessages).toBe(true);

act(() => service.sendResponse({ ...cited, id: 'w2' }));
expect(sources?.isWaitingForMoreMessages).toBe(false);
});
});
82 changes: 82 additions & 0 deletions packages/apollo-react/src/chat/headless/hooks/use-citations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';

import {
AutopilotChatEvent,
type AutopilotChatMessage,
AutopilotChatPreHookAction,
type PdfCitation,
type UrlCitation,
} from '../../service';
import { getCitationUrl, getGroupCitations } from '../markdown/citations';
import { useChatService } from '../providers/chat-service.provider';

/**
* Opens a citation as both renderers do: the `citation-click` pre-hook gets `{ citation }` and, if
* it allows, the citation's URL opens in a new tab. A URL that is not http(s) or relative is never
* opened, but the hook still runs with the unchanged citation so a host can handle it.
*/
export function useOpenCitation() {
const chatService = useChatService();

return React.useCallback(
(citation: UrlCitation | PdfCitation) => {
if (!chatService) {
return;
}

chatService
.getPreHook(AutopilotChatPreHookAction.CitationClick)({ citation })
.then((proceed) => {
const url = getCitationUrl(citation);
if (proceed && url) {
window.open(url, '_blank', 'noopener,noreferrer');
}
});
},
[chatService]
);
}

/**
* The sources of a message's group, read from the service so streamed content parts count, and
* whether the host said more messages of the group are coming (the list waits for them).
*/
export function useMessageSources(message: AutopilotChatMessage) {
const chatService = useChatService();
const groupId = message.groupId ?? '';
const [sources, setSources] = React.useState<Array<UrlCitation | PdfCitation>>(() =>
getGroupCitations(chatService?.getMessagesInGroup(groupId) ?? [])
);
// From the message too: it can mount from the very Response that carries the flag.
const [isWaitingForMoreMessages, setIsWaitingForMoreMessages] = React.useState(
!!message.shouldWaitForMoreMessages
);

React.useEffect(() => {
if (!chatService) {
return;
}

const refresh = () => setSources(getGroupCitations(chatService.getMessagesInGroup(groupId)));
const unsubscribes = [
chatService.on(AutopilotChatEvent.Response, (response: AutopilotChatMessage) => {
if (response.groupId === groupId || response.id === message.id) {
refresh();
setIsWaitingForMoreMessages(!!response.shouldWaitForMoreMessages);
}
}),
chatService.on(AutopilotChatEvent.SendChunk, (chunk: AutopilotChatMessage) => {
if (chunk.groupId === groupId || chunk.id === message.id) {
refresh();
}
}),
chatService.on(AutopilotChatEvent.StopResponse, refresh),
];

return () => {
for (const unsubscribe of unsubscribes) unsubscribe();
};
}, [chatService, message.id, groupId]);

return { sources, isWaitingForMoreMessages };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { act, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { type AutopilotChatMessage, AutopilotChatService } from '../../service';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import { AutopilotStreamingProvider } from '../providers/streaming-provider';
import { useStreamedMessageContent } from './use-streamed-message-content';

let instanceCounter = 0;
let latest: ReturnType<typeof useStreamedMessageContent> | undefined;

function Probe({ message }: { message: AutopilotChatMessage }) {
latest = useStreamedMessageContent(message);
return null;
}

const reply = (extra: Partial<AutopilotChatMessage> = {}) => ({
widget: 'apollo-markdown-renderer',
created_at: '',
...extra,
});

function setup(first: Partial<AutopilotChatMessage>) {
const service = AutopilotChatService.Instantiate({
instanceName: `streamed-content-${++instanceCounter}`,
});
service.sendResponse(reply(first));
const message = service.getConversation()[0] as AutopilotChatMessage;
render(
<AutopilotChatServiceProvider chatServiceInstance={service}>
<AutopilotStreamingProvider>
<Probe message={message} />
</AutopilotStreamingProvider>
</AutopilotChatServiceProvider>
);
return service;
}

describe('useStreamedMessageContent', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('renders content parts as citation markup', () => {
setup({
id: 'p1',
content: 'Sky',
contentParts: [{ text: 'Sky', citations: [{ id: 1, title: 'Web', url: 'https://a.test' }] }],
});

expect(latest?.content).toMatch(/^\[\[cite-start:.+\]\]Sky\[\[cite-end\]\]$/);
});

it('holds back a resource token until it closes, and flushes on done', () => {
const now = vi.spyOn(Date, 'now');
const service = setup({ id: 's1', content: 'Use ', stream: true });

now.mockReturnValue(1_000);
act(() =>
service.sendResponse(
reply({ id: 's1', content: 'the [[resource-token:{"id":', stream: true })
)
);
expect(latest?.content).toBe('Use the ');

now.mockReturnValue(2_000);
act(() =>
service.sendResponse(
reply({ id: 's1', content: '"a","type":"v","displayName":"A"}]] now', stream: true })
)
);
expect(latest?.content).toBe(
'Use the [[resource-token:{"id":"a","type":"v","displayName":"A"}]] now'
);

// Within 50ms of the last update a chunk waits; the closing chunk flushes it.
act(() => service.sendResponse(reply({ id: 's1', content: ' and', stream: true })));
expect(latest?.content).toMatch(/now$/);
act(() =>
service.sendResponse(reply({ id: 's1', content: ' done', stream: true, done: true }))
);
expect(latest?.content).toMatch(/now and done$/);
});

it('reveals a fake stream a few characters at a time', () => {
vi.useFakeTimers();
setup({ id: 'f1', content: 'A fake streamed reply', fakeStream: true });

expect(latest).toEqual({ content: '', fakeStreaming: true });
act(() => vi.advanceTimersByTime(50));
expect(latest?.content).toBe('A fake str');
act(() => vi.advanceTimersByTime(200));
expect(latest).toEqual({ content: 'A fake streamed reply', fakeStreaming: false });
});

it('shows the whole reply and stops revealing when the response is stopped', () => {
vi.useFakeTimers();
const clearInterval = vi.spyOn(globalThis, 'clearInterval');
const service = setup({ id: 'f2', content: 'A fake streamed reply', fakeStream: true });
act(() => vi.advanceTimersByTime(50));
expect(latest?.content).toBe('A fake str');

act(() => service.stopResponse());

expect(latest).toEqual({ content: 'A fake streamed reply', fakeStreaming: false });
expect(clearInterval).toHaveBeenCalled();
act(() => vi.advanceTimersByTime(500));
expect(latest?.content).toBe('A fake streamed reply');
});
});
Loading
Loading