8 changed files with 461 additions and 369 deletions
-
302client/src/api.ts
-
251client/src/chat.ts
-
2client/src/config.ts
-
4client/src/dom.ts
-
22client/src/types.ts
-
123client/tests/api.test.ts
-
118client/tests/chat.test.ts
-
8client/tests/config.test.ts
@ -0,0 +1,123 @@ |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
|||
import { chatRequest, chatSSERequest, clearApiConfig, setApiConfig } from '../src/api'; |
|||
import { parseConfig } from '../src/config'; |
|||
import type { RagSource } from '../src/types'; |
|||
|
|||
const source: RagSource = { |
|||
documentId: '1234567890123456789', title: 'manual', sourceName: null, |
|||
chunkIndex: 0, score: null, snippet: '实际命中的文档', |
|||
}; |
|||
const encoder = new TextEncoder(); |
|||
const chunk = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}\r\n\r\n`; |
|||
const metadata = `data: ${JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources: [source] })}\r\n\r\n`; |
|||
|
|||
function response(parts: Uint8Array[], close = true) { |
|||
const cancel = vi.fn(); |
|||
const stream = new ReadableStream<Uint8Array>({ |
|||
start(controller) { |
|||
parts.forEach(part => controller.enqueue(part)); |
|||
if (close) controller.close(); |
|||
}, |
|||
cancel, |
|||
}); |
|||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(stream))); |
|||
return { stream, cancel }; |
|||
} |
|||
function receive(signal?: AbortSignal) { |
|||
const onChunk = vi.fn(); |
|||
const onDone = vi.fn(); |
|||
const onError = vi.fn(); |
|||
const onSources = vi.fn(); |
|||
const pending = chatSSERequest('问题', onChunk, onDone, onError, 7, true, undefined, signal, onSources); |
|||
return { pending, onChunk, onDone, onError, onSources }; |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', userId: 'user' })!; |
|||
config.chatId = 'conversation'; |
|||
setApiConfig(config); |
|||
}); |
|||
afterEach(() => { clearApiConfig(); vi.unstubAllGlobals(); }); |
|||
|
|||
describe('same-request answer transport', () => { |
|||
it('stops on DONE without EOF, preserving split UTF-8, CRLF and sources outside text', async () => { |
|||
const bytes = encoder.encode(chunk('中文 ') + metadata + 'data: [DONE]\r\n\r\n' + chunk('不得出现')); |
|||
const { stream, cancel } = response(Array.from(bytes, byte => Uint8Array.of(byte)), false); |
|||
const result = receive(); |
|||
await result.pending; |
|||
expect(result.onChunk.mock.calls.flat().join('')).toBe('中文 '); |
|||
expect(result.onSources).toHaveBeenCalledExactlyOnceWith([source]); |
|||
expect(result.onDone).toHaveBeenCalledTimes(1); |
|||
expect(result.onError).not.toHaveBeenCalled(); |
|||
expect(cancel).toHaveBeenCalledTimes(1); |
|||
expect(stream.locked).toBe(false); |
|||
expect(fetch).toHaveBeenCalledTimes(1); |
|||
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string); |
|||
expect(url.pathname).toBe('/ai/chat/stream'); |
|||
expect(url.searchParams.get('rewriteStrategy')).toBe('NONE'); |
|||
}); |
|||
|
|||
it('keeps multiline data whitespace and flushes the last unterminated event at EOF', async () => { |
|||
response([encoder.encode('event: status\ndata: 不显示\n\ndata: first \r\ndata: second\r\n\r\ndata: last ')]); |
|||
const result = receive(); |
|||
await result.pending; |
|||
expect(result.onChunk.mock.calls.flat()).toEqual([' first \nsecond', 'last ']); |
|||
expect(result.onDone).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('recognizes EOF remainder DONE rather than rendering it', async () => { |
|||
response([encoder.encode(chunk('answer') + 'data: [DONE]')]); |
|||
const result = receive(); |
|||
await result.pending; |
|||
expect(result.onChunk).toHaveBeenCalledExactlyOnceWith('answer'); |
|||
expect(result.onDone).toHaveBeenCalledTimes(1); |
|||
}); |
|||
|
|||
it('delivers original server error once even when reader cleanup rejects', async () => { |
|||
const { cancel, stream } = response([encoder.encode('data: {"error":{"message":"权限不足"}}\n\n')], false); |
|||
cancel.mockRejectedValue(new Error('cleanup failed')); |
|||
const result = receive(); |
|||
await result.pending; |
|||
expect(result.onError).toHaveBeenCalledTimes(1); |
|||
expect(result.onError.mock.calls[0]![0].message).toBe('权限不足'); |
|||
expect(result.onDone).not.toHaveBeenCalled(); |
|||
expect(stream.locked).toBe(false); |
|||
}); |
|||
|
|||
it('cancels a pending read once without consuming later metadata', async () => { |
|||
const { stream } = response([], false); |
|||
const controller = new AbortController(); |
|||
const result = receive(controller.signal); |
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
controller.abort(); |
|||
await result.pending; |
|||
expect(result.onDone).toHaveBeenCalledTimes(1); |
|||
expect(result.onError).not.toHaveBeenCalled(); |
|||
expect(result.onSources).not.toHaveBeenCalled(); |
|||
expect(stream.locked).toBe(false); |
|||
}); |
|||
|
|||
it('uses one synchronous JSON request with the same RAG, role and category parameters', async () => { |
|||
const answer = { text: 'answer', mcpEvents: [], suggestions: [], sources: [source] }; |
|||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(answer))); |
|||
expect(await chatRequest('问题', ['https://example.test/photo.png'], 7, true)).toEqual(answer); |
|||
expect(fetch).toHaveBeenCalledTimes(1); |
|||
const url = new URL(vi.mocked(fetch).mock.calls[0]![0] as string); |
|||
expect(url.pathname).toBe('/ai/chat/result'); |
|||
expect(Object.fromEntries(url.searchParams)).toMatchObject({ |
|||
roleId: '42', accountId: 'user', chatId: 'conversation', categoryId: '7', enableRag: 'true', rewriteStrategy: 'NONE', |
|||
}); |
|||
}); |
|||
|
|||
it('retains explicit rewrite configuration and disabled RAG', async () => { |
|||
const config = parseConfig({ integrateId: '42', requestDomain: 'https://example.test', rewriteStrategy: 'MULTI_QUERY' })!; |
|||
setApiConfig(config); |
|||
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(Response.json({ text: '', mcpEvents: [], suggestions: [], sources: [] })))); |
|||
await chatRequest('问题', undefined, undefined, true); |
|||
await chatRequest('问题', undefined, undefined, false); |
|||
const urls = vi.mocked(fetch).mock.calls.map(([url]) => new URL(url as string)); |
|||
expect(urls[0]!.searchParams.get('rewriteStrategy')).toBe('MULTI_QUERY'); |
|||
expect(urls[1]!.searchParams.get('enableRag')).toBe('false'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,118 @@ |
|||
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(), []); |
|||
}); |
|||
}); |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue