本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

118 lines
5.7 KiB

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initChat, sendQuickReply } from '../src/chat';
import { setApiConfig, clearApiConfig } from '../src/api';
import { parseConfig } from '../src/config';
import { renderAIBubble, createEmptyAIBubble, renderSources } from '../src/dom';
import { saveMessages } from '../src/storage';
vi.mock('../src/dom', () => ({
renderUserBubble: vi.fn(), renderAIBubble: vi.fn(() => ({ id: 'sync-wrapper' })),
createEmptyAIBubble: vi.fn(() => ({ wrapper: { id: 'stream-wrapper' }, bubble: {} })),
renderSources: vi.fn(), finalizeAIBubble: vi.fn(), scrollToBottom: vi.fn(),
hideOfflineBanner: vi.fn(), showOfflineBanner: vi.fn(), renderErrorBubble: vi.fn(),
}));
vi.mock('../src/storage', () => ({ saveMessages: vi.fn(), clearMessages: vi.fn() }));
class ElementStub extends EventTarget {
style: Record<string, string> = {};
classList = { add: vi.fn(), remove: vi.fn() };
setAttribute() {}
removeAttribute() {}
querySelector() { return null; }
querySelectorAll() { return []; }
}
const encoder = new TextEncoder();
const source = { documentId: '1234567890123456789', title: 'same answer', sourceName: null, chunkIndex: null, score: null, snippet: null };
const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`;
const answer = event({ choices: [{ delta: { content: 'answer' } }] });
const sources = event({ choices: [], sources: [source] });
let clearButton: ElementStub;
let sender: ElementStub;
function setup(streaming: boolean) {
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', streaming, suggestions: false })!;
config.chatId = 'conversation';
setApiConfig(config);
clearButton = new ElementStub();
sender = new ElementStub();
initChat(config, {
messagesContainer: new ElementStub(), inputEl: sender, clearBtn: clearButton,
categorySelect: null, roleSelect: null, historyPanel: new ElementStub(),
welcomeEl: new ElementStub(), newMsgBtn: new ElementStub(), searchInput: null,
ariaLiveEl: new ElementStub(), showLoading: vi.fn(), hideLoading: vi.fn(),
} as unknown as Parameters<typeof initChat>[1]);
clearButton.dispatchEvent(new Event('click'));
vi.clearAllMocks();
}
function stream(body: string) {
return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(encoder.encode(body)); c.close(); } }));
}
beforeEach(() => { vi.clearAllMocks(); });
afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); });
describe('SDK message references belong to the producing request', () => {
it.each([true, false])('makes one answer request and persists/renders its sources (streaming=%s)', async streaming => {
setup(streaming);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(streaming
? stream(answer + sources + 'data: [DONE]\n\n')
: Response.json({ text: 'answer', mcpEvents: [], suggestions: [], sources: [source] })));
await sendQuickReply('question');
expect(fetch).toHaveBeenCalledTimes(1);
expect(new URL(vi.mocked(fetch).mock.calls[0]![0] as string).pathname).toBe(streaming ? '/ai/chat/stream' : '/ai/chat/result');
const saved = vi.mocked(saveMessages).mock.calls.at(-1)![1];
expect(saved.at(-1)).toMatchObject({ role: 'ai', content: 'answer', sources: [source] });
const wrapper = streaming
? vi.mocked(createEmptyAIBubble).mock.results[0]!.value.wrapper
: vi.mocked(renderAIBubble).mock.results[0]!.value;
expect(renderSources).toHaveBeenLastCalledWith(wrapper, [source]);
});
it('does not turn an empty completed stream into a second answer request', async () => {
setup(true);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(stream('data: [DONE]\n\n')));
await sendQuickReply('question');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('ignores a delayed synchronous answer after a new conversation begins', async () => {
setup(false);
let reply!: (response: Response) => void;
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(resolve => { reply = resolve; })));
const pending = sendQuickReply('old question');
clearButton.dispatchEvent(new Event('click'));
reply(Response.json({ text: 'old answer', mcpEvents: [], suggestions: [], sources: [source] }));
await pending;
expect(saveMessages).not.toHaveBeenCalled();
expect(renderSources).not.toHaveBeenCalled();
expect(renderAIBubble).not.toHaveBeenCalled();
});
it('cancels in-flight old streams without adding their sources to a new conversation', async () => {
setup(true);
const cancel = vi.fn();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(encoder.encode(answer)); }, cancel,
}))));
const pending = sendQuickReply('old question');
await vi.waitFor(() => expect(createEmptyAIBubble).toHaveBeenCalledTimes(1));
clearButton.dispatchEvent(new Event('click'));
await pending;
expect(cancel).toHaveBeenCalledTimes(1);
expect(saveMessages).not.toHaveBeenCalled();
expect(renderSources).not.toHaveBeenCalled();
});
it('retains partial text but removes references on user cancellation', async () => {
setup(true);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { controller.enqueue(encoder.encode(answer + sources)); },
}))));
const pending = sendQuickReply('question');
await vi.waitFor(() => expect(renderSources).toHaveBeenCalled());
sender.dispatchEvent(new Event('stop'));
await pending;
expect(vi.mocked(saveMessages).mock.calls.at(-1)![1].at(-1)).toMatchObject({ content: 'answer', sources: [] });
expect(renderSources).toHaveBeenLastCalledWith(expect.anything(), []);
});
});