test: verify real kbqa workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { Button } from '../../components/ui/button'
|
||||
@@ -17,17 +17,30 @@ export function ChatPage() {
|
||||
const navigate = useNavigate()
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [evidenceOpen, setEvidenceOpen] = useState(false)
|
||||
const currentSessionId = useRef(sessionId)
|
||||
const stream = useChatStreamStore()
|
||||
|
||||
useEffect(() => { currentSessionId.current = sessionId }, [sessionId])
|
||||
|
||||
const refreshMessages = useCallback(async () => {
|
||||
if (!sessionId) { setMessages([]); return }
|
||||
const page = await listMessages(sessionId)
|
||||
if (!sessionId) {
|
||||
if (currentSessionId.current === sessionId) setMessages([])
|
||||
return
|
||||
}
|
||||
const target = sessionId
|
||||
const page = await listMessages(target)
|
||||
if (currentSessionId.current !== target) return
|
||||
setMessages(page.items); announceSessionChange()
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
void listMessages(sessionId).then((page) => setMessages(page.items))
|
||||
const target = sessionId
|
||||
let cancelled = false
|
||||
void listMessages(target).then((page) => {
|
||||
if (!cancelled && currentSessionId.current === target) setMessages(page.items)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId])
|
||||
|
||||
const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
|
||||
@@ -38,6 +51,10 @@ export function ChatPage() {
|
||||
const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
|
||||
}
|
||||
await send(target, query)
|
||||
const page = await listMessages(target)
|
||||
if (currentSessionId.current !== target) return
|
||||
setMessages(page.items)
|
||||
announceSessionChange()
|
||||
}
|
||||
|
||||
function chooseSource(source: Source, sources: Source[] = stream.sources) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseSSEBlock, streamSSE } from './sse'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('parseSSEBlock', () => {
|
||||
it('parses Chinese token events with CRLF framing', () => {
|
||||
expect(parseSSEBlock('event: token\r\ndata: {"content":"知识"}')).toEqual({
|
||||
event: 'token',
|
||||
data: { content: '知识' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores heartbeat comments', () => {
|
||||
expect(parseSSEBlock(': ping')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamSSE', () => {
|
||||
it('incrementally decodes arbitrary UTF-8 chunks, CRLF, multiple events, and a tail event', async () => {
|
||||
const bytes = new TextEncoder().encode(
|
||||
'event: status\r\ndata: {"phase":"researching"}\r\n\r\n' +
|
||||
'event: token\ndata: {"content":"知识"}\n\n' +
|
||||
'event: done\ndata: {"message_id":"m1"}',
|
||||
)
|
||||
const chunks = [bytes.slice(0, 7), bytes.slice(7, 58), bytes.slice(58, 61), bytes.slice(61)]
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body))
|
||||
const controller = new AbortController()
|
||||
const events: unknown[] = []
|
||||
|
||||
await streamSSE('/stream', { query: 'q' }, (event) => events.push(event), controller.signal)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ event: 'status', data: { phase: 'researching' } },
|
||||
{ event: 'token', data: { content: '知识' } },
|
||||
{ event: 'done', data: { message_id: 'm1' } },
|
||||
])
|
||||
expect(fetchMock).toHaveBeenCalledWith('/stream', expect.objectContaining({ signal: controller.signal }))
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { ApiError } from './api'
|
||||
import type { SSEEvent } from './types'
|
||||
|
||||
function parseBlock(block: string): SSEEvent | null {
|
||||
export function parseSSEBlock(block: string): SSEEvent | null {
|
||||
let eventName = ''
|
||||
const dataLines: string[] = []
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
@@ -47,14 +47,14 @@ export async function streamSSE(
|
||||
while (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const event = parseBlock(block)
|
||||
const event = parseSSEBlock(block)
|
||||
if (event) onEvent(event)
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const event = parseBlock(buffer)
|
||||
const event = parseSSEBlock(buffer)
|
||||
if (event) onEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user