fix(conversation): 离屏不丢回复、列表预热 WS 与非阻塞进入聊天
- 后端:文本/转写后 AI 生成改为独立任务,避免断连取消整轮;按需 TTS 等与 WS 改动 - 前端:RealtimeSession 重绑 UI 时恢复流式 buffer;列表 onPressIn/挂载预热、已有会话立即 push - 同步会话相关类型、i18n、测试与 env/资源等累计改动 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,7 +9,7 @@ jest.mock('@/core/auth/token-manager', () => ({
|
||||
|
||||
jest.mock('@/core/config', () => ({
|
||||
config: {
|
||||
wsBaseUrl: 'ws://localhost:8000',
|
||||
wsBaseUrl: 'ws://localhost:8000/',
|
||||
ws: {
|
||||
reconnectMaxRetries: 3,
|
||||
reconnectBaseDelayMs: 10,
|
||||
@@ -23,6 +23,7 @@ jest.mock('@/core/config', () => ({
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
@@ -32,6 +33,7 @@ class MockWebSocket {
|
||||
sentMessages: string[] = [];
|
||||
|
||||
constructor(public url: string) {
|
||||
MockWebSocket.instances.push(this);
|
||||
setTimeout(() => this.onopen?.(), 0);
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ class MockWebSocket {
|
||||
describe('WsClient', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
MockWebSocket.instances = [];
|
||||
});
|
||||
|
||||
test('connects with token and conversation id in URL', async () => {
|
||||
@@ -66,6 +69,9 @@ describe('WsClient', () => {
|
||||
|
||||
expect(states).toContain('connecting');
|
||||
expect(states).toContain('connected');
|
||||
expect(MockWebSocket.instances[0]?.url).toBe(
|
||||
'ws://localhost:8000/ws/conversation/conv-123?token=test-token',
|
||||
);
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
@@ -133,6 +139,27 @@ describe('WsClient', () => {
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('sends text with tts_this_turn when requested', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
|
||||
await client.connect();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
client.sendText('Hello', { ttsThisTurn: true });
|
||||
|
||||
const ws = (client as unknown as { ws: MockWebSocket }).ws;
|
||||
expect(ws.sentMessages).toHaveLength(1);
|
||||
|
||||
const sent = JSON.parse(ws.sentMessages[0]);
|
||||
expect(sent).toEqual({
|
||||
type: 'text',
|
||||
conversation_id: 'conv-123',
|
||||
data: { text: 'Hello', tts_this_turn: true },
|
||||
});
|
||||
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('ignores unknown message types without crashing', async () => {
|
||||
const client = new WsClient('conv-123');
|
||||
const events: WsEvent[] = [];
|
||||
|
||||
128
app-expo/tests/features/conversation/entry-warmup.test.ts
Normal file
128
app-expo/tests/features/conversation/entry-warmup.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
prefetchConversationMessages,
|
||||
warmupConversationOpening,
|
||||
} from '@/features/conversation/entry-warmup';
|
||||
import { conversationKeys } from '@/features/conversation/query-keys';
|
||||
import type { MessageItem } from '@/features/conversation/types';
|
||||
|
||||
const mockLoadMessages = jest.fn();
|
||||
const mockRegisterPreparedRealtimeSession = jest.fn();
|
||||
let mockConnectImpl:
|
||||
| ((options: {
|
||||
conversationId: string;
|
||||
queryClient: QueryClient;
|
||||
}) => Promise<void> | void)
|
||||
| null = null;
|
||||
const mockSessions: Array<{
|
||||
attachUiCallbacks: jest.Mock;
|
||||
connect: jest.Mock;
|
||||
dispose: jest.Mock;
|
||||
}> = [];
|
||||
|
||||
jest.mock('@/features/conversation/conversation-messages-repository', () => ({
|
||||
conversationMessagesRepository: {
|
||||
loadMessages: (conversationId: string) => mockLoadMessages(conversationId),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/features/conversation/prepared-session-registry', () => ({
|
||||
registerPreparedRealtimeSession: (conversationId: string, session: unknown) =>
|
||||
mockRegisterPreparedRealtimeSession(conversationId, session),
|
||||
}));
|
||||
|
||||
jest.mock('@/features/conversation/realtime-session', () => ({
|
||||
RealtimeSession: jest.fn().mockImplementation((options) => {
|
||||
const session = {
|
||||
attachUiCallbacks: jest.fn(),
|
||||
connect: jest.fn(async () => {
|
||||
await mockConnectImpl?.(options);
|
||||
}),
|
||||
dispose: jest.fn(),
|
||||
};
|
||||
mockSessions.push(session);
|
||||
return session;
|
||||
}),
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assistantMessage(id = 'assistant-1'): MessageItem {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
content: '你好,今天想聊哪段回忆?',
|
||||
senderType: 'assistant',
|
||||
timestamp: 1,
|
||||
messageType: 'text',
|
||||
};
|
||||
}
|
||||
|
||||
describe('conversation entry warmup', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createQueryClient();
|
||||
mockLoadMessages.mockReset();
|
||||
mockRegisterPreparedRealtimeSession.mockReset();
|
||||
mockConnectImpl = null;
|
||||
mockSessions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
test('prefetches messages without throwing on load failure', async () => {
|
||||
mockLoadMessages.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(
|
||||
prefetchConversationMessages(queryClient, 'conv-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('uses refreshed history and skips websocket when opening is already cached', async () => {
|
||||
const existing = assistantMessage();
|
||||
mockLoadMessages.mockResolvedValueOnce([existing]);
|
||||
|
||||
await warmupConversationOpening(queryClient, 'conv-1');
|
||||
|
||||
expect(mockLoadMessages).toHaveBeenCalledWith('conv-1');
|
||||
expect(mockSessions).toHaveLength(0);
|
||||
expect(queryClient.getQueryData(conversationKeys.messages('conv-1'))).toEqual(
|
||||
[existing],
|
||||
);
|
||||
});
|
||||
|
||||
test('connects websocket and registers prepared session after opening arrives', async () => {
|
||||
const opened = assistantMessage();
|
||||
mockLoadMessages
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([opened]);
|
||||
mockConnectImpl = ({ conversationId, queryClient }) => {
|
||||
queryClient.setQueryData(conversationKeys.messages(conversationId), [
|
||||
opened,
|
||||
]);
|
||||
};
|
||||
|
||||
await warmupConversationOpening(queryClient, 'conv-1');
|
||||
|
||||
expect(mockSessions).toHaveLength(1);
|
||||
expect(mockSessions[0]?.attachUiCallbacks).toHaveBeenCalled();
|
||||
expect(mockSessions[0]?.connect).toHaveBeenCalled();
|
||||
expect(mockSessions[0]?.dispose).not.toHaveBeenCalled();
|
||||
expect(mockRegisterPreparedRealtimeSession).toHaveBeenCalledWith(
|
||||
'conv-1',
|
||||
mockSessions[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user