mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 04:07:41 +00:00
fix(web): clean up pipeline debug websocket
This commit is contained in:
@@ -150,6 +150,11 @@ export default function DebugDialog({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wsClientRef = useRef<WebSocketClient | null>(null);
|
const wsClientRef = useRef<WebSocketClient | null>(null);
|
||||||
const isInitializingRef = useRef<boolean>(false);
|
const isInitializingRef = useRef<boolean>(false);
|
||||||
|
const historyRequestGenerationRef = useRef(0);
|
||||||
|
|
||||||
|
const invalidateHistoryRequests = useCallback(() => {
|
||||||
|
historyRequestGenerationRef.current++;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -165,13 +170,16 @@ export default function DebugDialog({
|
|||||||
|
|
||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (pipelineId: string) => {
|
async (pipelineId: string) => {
|
||||||
|
const generation = ++historyRequestGenerationRef.current;
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.getWebSocketHistoryMessages(
|
const response = await httpClient.getWebSocketHistoryMessages(
|
||||||
pipelineId,
|
pipelineId,
|
||||||
sessionType,
|
sessionType,
|
||||||
);
|
);
|
||||||
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
setMessages(response.messages);
|
setMessages(response.messages);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== historyRequestGenerationRef.current) return;
|
||||||
console.error('Failed to load messages:', error);
|
console.error('Failed to load messages:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -186,24 +194,30 @@ export default function DebugDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wsClient: WebSocketClient | null = null;
|
||||||
|
let errorReported = false;
|
||||||
try {
|
try {
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
|
|
||||||
// Disconnect old connection
|
// Disconnect old connection
|
||||||
if (wsClientRef.current) {
|
const previousClient = wsClientRef.current;
|
||||||
wsClientRef.current.disconnect();
|
wsClientRef.current = null;
|
||||||
wsClientRef.current = null;
|
previousClient?.disconnect();
|
||||||
}
|
|
||||||
|
|
||||||
// Create new connection
|
// Create new connection
|
||||||
const wsClient = new WebSocketClient(pipelineId, sessionType);
|
wsClient = new WebSocketClient(pipelineId, sessionType);
|
||||||
|
// Store the client before awaiting connect so effect cleanup can also
|
||||||
|
// cancel sockets that are still authenticating.
|
||||||
|
wsClientRef.current = wsClient;
|
||||||
|
|
||||||
wsClient
|
wsClient
|
||||||
.onConnected(() => {
|
.onConnected(() => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
})
|
})
|
||||||
.onMessage((wsMessage) => {
|
.onMessage((wsMessage) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
// Convert WebSocketMessage to Message type
|
// Convert WebSocketMessage to Message type
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
...wsMessage,
|
...wsMessage,
|
||||||
@@ -228,26 +242,32 @@ export default function DebugDialog({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.onError((error) => {
|
.onError((error) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
|
errorReported = true;
|
||||||
console.error('WebSocket error:', error);
|
console.error('WebSocket error:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
toast.error(t('pipelines.debugDialog.connectionError'));
|
toast.error(t('pipelines.debugDialog.connectionError'));
|
||||||
})
|
})
|
||||||
.onClose(() => {
|
.onClose(() => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
})
|
})
|
||||||
.onBroadcast((message) => {
|
.onBroadcast((message) => {
|
||||||
|
if (wsClientRef.current !== wsClient) return;
|
||||||
toast.info(message);
|
toast.info(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
await wsClient.connect();
|
await wsClient.connect();
|
||||||
wsClientRef.current = wsClient;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!wsClient || wsClientRef.current !== wsClient) return;
|
||||||
console.error('WebSocket connection failed:', error);
|
console.error('WebSocket connection failed:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
if (!errorReported) {
|
||||||
|
toast.error(t('pipelines.debugDialog.connectionFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[sessionType, t],
|
[sessionType, t],
|
||||||
@@ -263,24 +283,28 @@ export default function DebugDialog({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setSelectedPipelineId(pipelineId);
|
setSelectedPipelineId(pipelineId);
|
||||||
} else {
|
} else {
|
||||||
|
invalidateHistoryRequests();
|
||||||
// Disconnect WebSocket immediately when dialog closes
|
// Disconnect WebSocket immediately when dialog closes
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
const wsClient = wsClientRef.current;
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
|
wsClient.disconnect();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
invalidateHistoryRequests();
|
||||||
// Disconnect WebSocket on component unmount
|
// Disconnect WebSocket on component unmount
|
||||||
if (wsClientRef.current) {
|
if (wsClientRef.current) {
|
||||||
wsClientRef.current.disconnect();
|
const wsClient = wsClientRef.current;
|
||||||
wsClientRef.current = null;
|
wsClientRef.current = null;
|
||||||
|
wsClient.disconnect();
|
||||||
isInitializingRef.current = false;
|
isInitializingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [open, pipelineId]);
|
}, [open, pipelineId, invalidateHistoryRequests]);
|
||||||
|
|
||||||
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
// Reload messages and reconnect when sessionType or selectedPipelineId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ export class WebSocketClient {
|
|||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 5;
|
private maxReconnectAttempts = 5;
|
||||||
private reconnectDelay = 3000; // 3秒重连间隔
|
private reconnectDelay = 3000; // 3秒重连间隔
|
||||||
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
private heartbeatIntervalMs = 30000; // 30秒
|
private heartbeatIntervalMs = 30000; // 30秒
|
||||||
private isConnecting = false; // 防止重复连接
|
private isConnecting = false; // 防止重复连接
|
||||||
|
private shouldReconnect = true;
|
||||||
|
private disconnectedByUser = false;
|
||||||
|
|
||||||
// 事件回调
|
// 事件回调
|
||||||
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
private onConnectedCallback?: (data: WebSocketResponse) => void;
|
||||||
@@ -54,6 +57,13 @@ export class WebSocketClient {
|
|||||||
public connect(): Promise<string> {
|
public connect(): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
|
this.disconnectedByUser = false;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
// 防止重复连接
|
// 防止重复连接
|
||||||
if (
|
if (
|
||||||
this.isConnecting ||
|
this.isConnecting ||
|
||||||
@@ -82,21 +92,27 @@ export class WebSocketClient {
|
|||||||
window.location.host;
|
window.location.host;
|
||||||
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
const url = `${protocol}//${host}/api/v1/pipelines/${this.pipelineId}/ws/connect?session_type=${this.sessionType}`;
|
||||||
|
|
||||||
this.ws = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
|
this.ws = socket;
|
||||||
|
|
||||||
// 连接打开
|
// 连接打开
|
||||||
this.ws.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
if (this.disconnectedByUser || this.ws !== socket) {
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
const token = this.token || localStorage.getItem('token');
|
const token = this.token || localStorage.getItem('token');
|
||||||
const workspaceUuid = getActiveWorkspaceUuid();
|
const workspaceUuid = getActiveWorkspaceUuid();
|
||||||
if (!token || !workspaceUuid) {
|
if (!token || !workspaceUuid) {
|
||||||
const error = new Error('WebSocket认证信息缺失');
|
const error = new Error('WebSocket认证信息缺失');
|
||||||
|
this.shouldReconnect = false;
|
||||||
this.onErrorCallback?.(error);
|
this.onErrorCallback?.(error);
|
||||||
this.ws?.close();
|
socket.close();
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.ws?.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'authenticate',
|
type: 'authenticate',
|
||||||
token,
|
token,
|
||||||
@@ -106,11 +122,17 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 接收消息
|
// 接收消息
|
||||||
this.ws.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
|
if (this.disconnectedByUser || this.ws !== socket) return;
|
||||||
try {
|
try {
|
||||||
const data: WebSocketResponse = JSON.parse(event.data);
|
const data: WebSocketResponse = JSON.parse(event.data);
|
||||||
this.handleMessage(data);
|
this.handleMessage(data);
|
||||||
|
|
||||||
|
if (data.type === 'error' && !this.connectionId) {
|
||||||
|
reject(new Error(data.message || 'WebSocket连接失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 第一次连接成功
|
// 第一次连接成功
|
||||||
if (data.type === 'connected' && data.connection_id) {
|
if (data.type === 'connected' && data.connection_id) {
|
||||||
// Only a fully authenticated runtime connection should reset
|
// Only a fully authenticated runtime connection should reset
|
||||||
@@ -128,22 +150,36 @@ export class WebSocketClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 连接关闭
|
// 连接关闭
|
||||||
this.ws.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
if (this.ws === socket) {
|
||||||
|
this.ws = null;
|
||||||
|
this.connectionId = null;
|
||||||
|
}
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
if (this.disconnectedByUser) return;
|
||||||
this.onCloseCallback?.();
|
this.onCloseCallback?.();
|
||||||
|
|
||||||
// 自动重连
|
// 自动重连
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
if (
|
||||||
|
this.shouldReconnect &&
|
||||||
|
this.reconnectAttempts < this.maxReconnectAttempts
|
||||||
|
) {
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
setTimeout(() => {
|
this.reconnectTimeout = setTimeout(() => {
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
if (!this.shouldReconnect || this.disconnectedByUser) return;
|
||||||
this.connect().catch(console.error);
|
this.connect().catch(console.error);
|
||||||
}, this.reconnectDelay * this.reconnectAttempts);
|
}, this.reconnectDelay * this.reconnectAttempts);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 连接错误
|
// 连接错误
|
||||||
this.ws.onerror = (event) => {
|
socket.onerror = (event) => {
|
||||||
|
if (this.disconnectedByUser || this.ws !== socket) {
|
||||||
|
reject(new Error('WebSocket连接已取消'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('WebSocket错误:', event);
|
console.error('WebSocket错误:', event);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
const error = new Error('WebSocket连接失败');
|
const error = new Error('WebSocket连接失败');
|
||||||
@@ -208,6 +244,13 @@ export class WebSocketClient {
|
|||||||
case 'error':
|
case 'error':
|
||||||
const error = new Error(data.message || '未知错误');
|
const error = new Error(data.message || '未知错误');
|
||||||
this.onErrorCallback?.(error);
|
this.onErrorCallback?.(error);
|
||||||
|
// Authentication/resource errors happen before the `connected`
|
||||||
|
// handshake. Retrying them cannot recover and would leak error toasts
|
||||||
|
// after the user leaves the Pipeline page.
|
||||||
|
if (!this.connectionId) {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -270,21 +313,27 @@ export class WebSocketClient {
|
|||||||
* 断开连接
|
* 断开连接
|
||||||
*/
|
*/
|
||||||
public disconnect() {
|
public disconnect() {
|
||||||
|
this.disconnectedByUser = true;
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
const socket = this.ws;
|
||||||
// 停止自动重连
|
|
||||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
|
||||||
|
|
||||||
// 发送断开消息
|
// 发送断开消息
|
||||||
if (this.ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
this.ws.send(JSON.stringify({ type: 'disconnect' }));
|
socket.send(JSON.stringify({ type: 'disconnect' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.connectionId = null;
|
this.connectionId = null;
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ test('agent and pipeline details share the split processor workbench', () => {
|
|||||||
websocketClient,
|
websocketClient,
|
||||||
/data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/,
|
/data\.type === 'connected'[\s\S]*this\.reconnectAttempts = 0/,
|
||||||
);
|
);
|
||||||
|
assert.match(websocketClient, /private reconnectTimeout:/);
|
||||||
|
assert.match(websocketClient, /private disconnectedByUser = false/);
|
||||||
|
assert.match(
|
||||||
|
websocketClient,
|
||||||
|
/if \(!this\.connectionId\)[\s\S]*this\.shouldReconnect = false/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
pipelineDebug,
|
||||||
|
/wsClientRef\.current = wsClient;[\s\S]*await wsClient\.connect\(\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
pipelineDebug,
|
||||||
|
/if \(wsClientRef\.current !== wsClient\) return;/,
|
||||||
|
);
|
||||||
assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/);
|
assert.match(pipelineDebug, /data-slot="scroll-area-viewport"/);
|
||||||
assert.doesNotMatch(pipelineDebug, /scrollIntoView/);
|
assert.doesNotMatch(pipelineDebug, /scrollIntoView/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user