refactor: switch webchat from sse to websocket (#1808)

* refactor: switch webchat from sse to websocket

* perf: image preview dialog

* chore: remove console.log
This commit is contained in:
Junyan Qin (Chin)
2025-11-28 14:54:01 +08:00
committed by GitHub
parent 348620ac0a
commit d09b823c49
39 changed files with 2656 additions and 783 deletions
+142 -1
View File
@@ -1,7 +1,148 @@
// Message component base interface
export interface MessageComponent {
type: string;
}
// Source component
export interface Source extends MessageComponent {
type: 'Source';
id: number | string;
timestamp: number;
}
// Plain text component
export interface Plain extends MessageComponent {
type: 'Plain';
text: string;
}
// Quote component
export interface Quote extends MessageComponent {
type: 'Quote';
id?: number;
group_id?: number | string;
sender_id?: number | string;
target_id?: number | string;
origin: MessageComponent[];
}
// At component
export interface At extends MessageComponent {
type: 'At';
target: number | string;
display?: string;
}
// AtAll component
export interface AtAll extends MessageComponent {
type: 'AtAll';
}
// Image component
export interface Image extends MessageComponent {
type: 'Image';
image_id?: string;
url?: string;
path?: string;
base64?: string;
}
// Voice component
export interface Voice extends MessageComponent {
type: 'Voice';
voice_id?: string;
url?: string;
path?: string;
base64?: string;
length?: number;
}
// File component
export interface File extends MessageComponent {
type: 'File';
id?: string;
name?: string;
size?: number;
url?: string;
}
// Unknown component
export interface Unknown extends MessageComponent {
type: 'Unknown';
text?: string;
}
// Forward message node
export interface ForwardMessageNode {
sender_id?: number | string;
sender_name?: string;
message_chain?: MessageComponent[];
message_id?: number;
}
// Forward message display
export interface ForwardMessageDisplay {
title?: string;
brief?: string;
source?: string;
preview?: string[];
summary?: string;
}
// Forward component
export interface Forward extends MessageComponent {
type: 'Forward';
display?: ForwardMessageDisplay;
node_list?: ForwardMessageNode[];
}
// WeChat specific components
export interface WeChatMiniPrograms extends MessageComponent {
type: 'WeChatMiniPrograms';
mini_app_id: string;
user_name: string;
display_name?: string;
page_path?: string;
title?: string;
image_url?: string;
}
export interface WeChatEmoji extends MessageComponent {
type: 'WeChatEmoji';
emoji_md5: string;
emoji_size: number;
}
export interface WeChatLink extends MessageComponent {
type: 'WeChatLink';
link_title?: string;
link_desc?: string;
link_url?: string;
link_thumb_url?: string;
}
// Union type for all message components
export type MessageChainComponent =
| Source
| Plain
| Quote
| At
| AtAll
| Image
| Voice
| File
| Unknown
| Forward
| WeChatMiniPrograms
| WeChatEmoji
| WeChatLink;
// Message interface
export interface Message {
id: number;
role: 'user' | 'assistant';
content: string;
message_chain: object[];
message_chain: MessageChainComponent[];
timestamp: string;
is_final?: boolean;
}
+42 -121
View File
@@ -22,7 +22,6 @@ import {
GetPipelineResponseData,
GetPipelineMetadataResponseData,
AsyncTask,
ApiRespWebChatMessage,
ApiRespWebChatMessages,
ApiRespKnowledgeBases,
ApiRespKnowledgeBase,
@@ -199,136 +198,58 @@ export class BackendClient extends BaseHttpClient {
});
}
// ============ Debug WebChat API ============
// ============ Debug WebChat API ============
public sendWebChatMessage(
sessionType: string,
messageChain: object[],
pipelineId: string,
timeout: number = 15000,
): Promise<ApiRespWebChatMessage> {
return this.post(
`/api/v1/pipelines/${pipelineId}/chat/send`,
{
session_type: sessionType,
message: messageChain,
},
{
timeout,
},
);
}
public async sendStreamingWebChatMessage(
sessionType: string,
messageChain: object[],
pipelineId: string,
onMessage: (data: ApiRespWebChatMessage) => void,
onComplete: () => void,
onError: (error: Error) => void,
): Promise<void> {
try {
// 构造完整的URL,处理相对路径的情况
let url = `${this.baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
if (this.baseURL === '/') {
// 获取用户访问的完整URL
const baseURL = window.location.origin;
url = `${baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
}
// 使用fetch发送流式请求,因为axios在浏览器环境中不直接支持流式响应
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getSessionSync()}`,
},
body: JSON.stringify({
session_type: sessionType,
message: messageChain,
is_stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('ReadableStream not supported');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取流式响应
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
onComplete();
break;
}
// 解码数据
buffer += decoder.decode(value, { stream: true });
// 处理完整的JSON对象
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.slice(5));
if (data.type === 'end') {
// 流传输结束
reader.cancel();
onComplete();
return;
}
if (data.type === 'start') {
console.log(data.type);
}
if (data.message) {
// 处理消息数据
onMessage(data);
}
} catch (error) {
console.error('Error parsing streaming data:', error);
}
}
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
onError(error as Error);
}
}
public getWebChatHistoryMessages(
// ============ WebSocket Chat API ============
public getWebSocketHistoryMessages(
pipelineId: string,
sessionType: string,
): Promise<ApiRespWebChatMessages> {
return this.get(
`/api/v1/pipelines/${pipelineId}/chat/messages/${sessionType}`,
`/api/v1/pipelines/${pipelineId}/ws/messages/${sessionType}`,
);
}
public resetWebChatSession(
public async uploadWebSocketImage(
pipelineId: string,
imageFile: File,
): Promise<{ file_key: string }> {
const formData = new FormData();
formData.append('file', imageFile);
return this.postFile(`/api/v1/files/images`, formData);
}
public resetWebSocketSession(
pipelineId: string,
sessionType: string,
): Promise<{ message: string }> {
return this.post(
`/api/v1/pipelines/${pipelineId}/chat/reset/${sessionType}`,
);
return this.post(`/api/v1/pipelines/${pipelineId}/ws/reset/${sessionType}`);
}
public getWebSocketConnections(pipelineId: string): Promise<{
stats: {
total_connections: number;
pipelines: number;
connections_by_pipeline: Record<string, number>;
connections_by_session_type: Record<string, number>;
};
connections: Array<{
connection_id: string;
session_type: string;
created_at: string;
last_active: string;
is_active: boolean;
}>;
}> {
return this.get(`/api/v1/pipelines/${pipelineId}/ws/connections`);
}
public broadcastWebSocketMessage(
pipelineId: string,
message: string,
): Promise<{ message: string }> {
return this.post(`/api/v1/pipelines/${pipelineId}/ws/broadcast`, {
message,
});
}
// ============ Platform API ============
-2
View File
@@ -97,8 +97,6 @@ export abstract class BaseHttpClient {
switch (status) {
case 401:
console.log('401 error: ', errMessage, error.request);
console.log('responseURL', error.request.responseURL);
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
if (!error.request.responseURL.includes('/check-token')) {
@@ -0,0 +1,295 @@
/**
* WebSocket客户端类
* 用于管理WebSocket连接和消息处理
*/
export interface WebSocketMessage {
id: number;
role: 'user' | 'assistant';
content: string;
message_chain: Array<{ type: string; text?: string; target?: string }>;
timestamp: string;
is_final?: boolean;
connection_id?: string;
}
export interface WebSocketResponse {
type:
| 'connected'
| 'response'
| 'user_message'
| 'pong'
| 'broadcast'
| 'error';
connection_id?: string;
pipeline_uuid?: string;
session_type?: string;
timestamp?: string;
data?: WebSocketMessage;
message?: string;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private connectionId: string | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000; // 3秒重连间隔
private heartbeatInterval: NodeJS.Timeout | null = null;
private heartbeatIntervalMs = 30000; // 30秒
private isConnecting = false; // 防止重复连接
// 事件回调
private onConnectedCallback?: (data: WebSocketResponse) => void;
private onMessageCallback?: (data: WebSocketMessage) => void;
private onErrorCallback?: (error: Error) => void;
private onCloseCallback?: () => void;
private onBroadcastCallback?: (message: string) => void;
constructor(
private pipelineId: string,
private sessionType: 'person' | 'group' = 'person',
private token?: string,
) {}
/**
* 连接到WebSocket服务器
*/
public connect(): Promise<string> {
return new Promise((resolve, reject) => {
try {
// 防止重复连接
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.CONNECTING)
) {
console.warn('WebSocket正在连接中,忽略重复连接请求');
reject(new Error('Connection already in progress'));
return;
}
// 如果已经连接,直接返回
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.warn('WebSocket已连接,忽略重复连接请求');
resolve(this.connectionId || '');
return;
}
this.isConnecting = true;
// 构建WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// extract host from process.env.NEXT_PUBLIC_API_BASE_URL
const host =
process.env.NEXT_PUBLIC_API_BASE_URL?.split('://')[1] || '';
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
this.ws = new WebSocket(url);
// 连接打开
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.isConnecting = false;
this.startHeartbeat();
};
// 接收消息
this.ws.onmessage = (event) => {
try {
const data: WebSocketResponse = JSON.parse(event.data);
this.handleMessage(data);
// 第一次连接成功
if (data.type === 'connected' && data.connection_id) {
this.connectionId = data.connection_id;
resolve(data.connection_id);
}
} catch (error) {
console.error('解析WebSocket消息失败:', error);
this.onErrorCallback?.(error as Error);
}
};
// 连接关闭
this.ws.onclose = () => {
this.isConnecting = false;
this.stopHeartbeat();
this.onCloseCallback?.();
// 自动重连
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => {
this.connect().catch(console.error);
}, this.reconnectDelay * this.reconnectAttempts);
}
};
// 连接错误
this.ws.onerror = (event) => {
console.error('WebSocket错误:', event);
this.isConnecting = false;
const error = new Error('WebSocket连接失败');
this.onErrorCallback?.(error);
reject(error);
};
} catch (error) {
this.isConnecting = false;
reject(error);
}
});
}
/**
* 处理接收到的消息
*/
private handleMessage(data: WebSocketResponse) {
switch (data.type) {
case 'connected':
this.onConnectedCallback?.(data);
break;
case 'response':
if (data.data) {
this.onMessageCallback?.(data.data);
}
break;
case 'user_message':
// 用户消息广播(包括自己发送的消息)
if (data.data) {
this.onMessageCallback?.(data.data);
}
break;
case 'pong':
// 心跳响应
break;
case 'broadcast':
if (data.message) {
this.onBroadcastCallback?.(data.message);
}
break;
case 'error':
const error = new Error(data.message || '未知错误');
this.onErrorCallback?.(error);
break;
default:
console.warn('未知消息类型:', data);
}
}
/**
* 发送消息
*/
public sendMessage(
messageChain: Array<{ type: string; text?: string; target?: string }>,
) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket未连接');
}
const message = {
type: 'message',
message: messageChain,
};
this.ws.send(JSON.stringify(message));
}
/**
* 发送心跳
*/
private sendHeartbeat() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
this.ws.send(JSON.stringify({ type: 'ping' }));
}
/**
* 启动心跳
*/
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
this.sendHeartbeat();
}, this.heartbeatIntervalMs);
}
/**
* 停止心跳
*/
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* 断开连接
*/
public disconnect() {
if (this.ws) {
this.stopHeartbeat();
// 停止自动重连
this.reconnectAttempts = this.maxReconnectAttempts;
// 发送断开消息
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'disconnect' }));
}
this.ws.close();
this.ws = null;
this.connectionId = null;
this.isConnecting = false;
}
}
/**
* 获取连接ID
*/
public getConnectionId(): string | null {
return this.connectionId;
}
/**
* 获取连接状态
*/
public isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
// ===== 事件回调设置 =====
public onConnected(callback: (data: WebSocketResponse) => void) {
this.onConnectedCallback = callback;
return this;
}
public onMessage(callback: (data: WebSocketMessage) => void) {
this.onMessageCallback = callback;
return this;
}
public onError(callback: (error: Error) => void) {
this.onErrorCallback = callback;
return this;
}
public onClose(callback: () => void) {
this.onCloseCallback = callback;
return this;
}
public onBroadcast(callback: (message: string) => void) {
this.onBroadcastCallback = callback;
return this;
}
}