mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 14:26:06 +00:00
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:
committed by
GitHub
parent
348620ac0a
commit
d09b823c49
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user