- DB: segments 用户输入文本(Alembic 0002) - Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整 - Memoir: 忠实度检查 agent,叙事与分类等链路更新 - Core: agent 日志、Alembic 启动、LangChain/日志/配置等 - Story: time_hints;Memory 检索与相关测试 - Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n - Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
import type { QueryClient } from '@tanstack/react-query';
|
||
|
||
import {
|
||
WsClient,
|
||
type WsEventListener,
|
||
type WsStateListener,
|
||
} from '@/core/ws/client';
|
||
import type { WsConnectionState, WsEvent } from '@/core/ws/types';
|
||
|
||
import { handleWsEvent } from './event-handlers';
|
||
import { lastSegmentPreview } from './message-split';
|
||
import { conversationKeys } from './query-keys';
|
||
import type { ConversationListItem, MessageItem } from './types';
|
||
|
||
export type StreamingTextCallback = (text: string, isComplete: boolean) => void;
|
||
export type ErrorCallback = (message: string, code?: string) => void;
|
||
|
||
/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */
|
||
export type TtsSegmentPayload = {
|
||
audioBase64?: string;
|
||
audioUrl?: string;
|
||
};
|
||
|
||
interface RealtimeSessionOptions {
|
||
conversationId: string;
|
||
queryClient: QueryClient;
|
||
onStreamingText?: StreamingTextCallback;
|
||
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
|
||
onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||
onError?: ErrorCallback;
|
||
onStateChange?: WsStateListener;
|
||
}
|
||
|
||
/**
|
||
* Orchestrates a single WS conversation session.
|
||
* Responsibilities:
|
||
* - Connect/disconnect WS
|
||
* - Route confirmed events to cache handlers
|
||
* - Accumulate agent_response chunks, commit final message to cache
|
||
* - Forward streaming text to UI callback
|
||
* - Expose send methods (return success/failure)
|
||
*
|
||
* Does NOT hold message lists or UI state.
|
||
*/
|
||
export class RealtimeSession {
|
||
private client: WsClient;
|
||
private conversationId: string;
|
||
private queryClient: QueryClient;
|
||
private onStreamingText?: StreamingTextCallback;
|
||
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
|
||
private onError?: ErrorCallback;
|
||
private unsubEvent: (() => void) | null = null;
|
||
private unsubState: (() => void) | null = null;
|
||
|
||
private streamingBuffer = '';
|
||
|
||
constructor(options: RealtimeSessionOptions) {
|
||
this.client = new WsClient(options.conversationId);
|
||
this.conversationId = options.conversationId;
|
||
this.queryClient = options.queryClient;
|
||
this.onStreamingText = options.onStreamingText;
|
||
this.onTtsSegment = options.onTtsSegment;
|
||
this.onError = options.onError;
|
||
|
||
this.unsubEvent = this.client.onEvent(this.handleEvent);
|
||
|
||
if (options.onStateChange) {
|
||
this.unsubState = this.client.onStateChange(options.onStateChange);
|
||
}
|
||
}
|
||
|
||
async connect(): Promise<void> {
|
||
await this.client.connect();
|
||
}
|
||
|
||
disconnect(): void {
|
||
this.client.disconnect();
|
||
}
|
||
|
||
dispose(): void {
|
||
this.commitStreamingBuffer();
|
||
this.unsubEvent?.();
|
||
this.unsubState?.();
|
||
this.client.dispose();
|
||
}
|
||
|
||
/** Returns true if the message was sent over the socket. */
|
||
sendText(text: string): boolean {
|
||
return this.client.sendText(text);
|
||
}
|
||
|
||
sendAudioSegment(
|
||
audioBase64: string,
|
||
segmentIndex: number,
|
||
options?: {
|
||
voiceSessionId?: string;
|
||
clientSegmentId?: string;
|
||
isLast?: boolean;
|
||
duration?: number;
|
||
},
|
||
): boolean {
|
||
return this.client.send({
|
||
type: 'audio_segment',
|
||
data: {
|
||
audio_base64: audioBase64,
|
||
segment_index: segmentIndex,
|
||
voice_session_id: options?.voiceSessionId,
|
||
client_segment_id: options?.clientSegmentId,
|
||
is_last: options?.isLast,
|
||
duration: options?.duration,
|
||
},
|
||
});
|
||
}
|
||
|
||
sendEndConversation(): boolean {
|
||
return this.client.sendEndConversation();
|
||
}
|
||
|
||
getConnectionState(): WsConnectionState {
|
||
return this.client.getState();
|
||
}
|
||
|
||
// ─── Internal ───
|
||
|
||
private handleEvent: WsEventListener = (event: WsEvent) => {
|
||
if (event.kind === 'agent_response') {
|
||
this.handleAgentChunk(event);
|
||
return;
|
||
}
|
||
|
||
if (event.kind === 'tts_audio_received') {
|
||
const b64 = event.audioBase64?.trim();
|
||
const url = event.audioUrl?.trim();
|
||
if (b64 || url) {
|
||
this.onTtsSegment?.({
|
||
audioBase64: b64 || undefined,
|
||
audioUrl: url || undefined,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
handleWsEvent(this.queryClient, event);
|
||
|
||
if (event.kind === 'session_error') {
|
||
this.onError?.(event.message, event.code);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Accumulates agent_response chunks into streamingBuffer.
|
||
* Only commits the final aggregated message to Query cache
|
||
* when the last chunk arrives (index >= total - 1).
|
||
* Individual chunks are forwarded to onStreamingText for UI display.
|
||
*/
|
||
private handleAgentChunk(
|
||
event: Extract<WsEvent, { kind: 'agent_response' }>,
|
||
): void {
|
||
this.streamingBuffer += event.text;
|
||
|
||
const isComplete =
|
||
event.index !== undefined &&
|
||
event.total !== undefined &&
|
||
event.index >= event.total - 1;
|
||
|
||
this.onStreamingText?.(this.streamingBuffer, isComplete);
|
||
|
||
if (isComplete) {
|
||
this.commitStreamingBuffer();
|
||
}
|
||
}
|
||
|
||
private commitStreamingBuffer(): void {
|
||
if (!this.streamingBuffer) return;
|
||
|
||
const fullText = this.streamingBuffer;
|
||
this.streamingBuffer = '';
|
||
|
||
const messagesKey = conversationKeys.messages(this.conversationId);
|
||
this.queryClient.setQueryData<MessageItem[]>(messagesKey, (old) => {
|
||
const message: MessageItem = {
|
||
id: `${this.conversationId}_agent_${Date.now()}`,
|
||
conversationId: this.conversationId,
|
||
content: fullText,
|
||
senderType: 'assistant',
|
||
timestamp: Date.now(),
|
||
messageType: 'text',
|
||
};
|
||
return [...(old ?? []), message];
|
||
});
|
||
|
||
this.queryClient.setQueryData<ConversationListItem[]>(
|
||
conversationKeys.lists(),
|
||
(old) => {
|
||
if (!old) return old;
|
||
return old.map((item) =>
|
||
item.id === this.conversationId
|
||
? {
|
||
...item,
|
||
latestMessagePreview: lastSegmentPreview(fullText, 50),
|
||
latestMessageTime: Date.now(),
|
||
}
|
||
: item,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|