Files
ai-code-app/src/app/main/mainChatPanel/useChatConnection.ts

676 lines
20 KiB
TypeScript
Executable File

import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useState } from 'react';
import {
CHAT_CONNECTION,
diagnoseConnectionFailure,
getLastReceivedChatEventId,
handleChatServerEvent,
persistLastReceivedChatEventId,
resolveChatWebSocketUrl,
} from './chatUtils';
import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
import type {
ChatActivityEvent,
ChatJobEvent,
ChatMessage,
ChatRuntimeJobDetail,
ChatRuntimeSnapshot,
ChatViewContext,
} from './types';
const DISCONNECT_UI_DELAY_MS = 1500;
const PRESENCE_PING_INTERVAL_MS = 20_000;
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
type UseChatConnectionOptions = {
sessionId: string;
currentContext: ChatViewContext;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
onMessageEvent?: (message: ChatMessage, sessionId: string) => void;
onJobEvent?: (event: ChatJobEvent, sessionId: string) => void;
onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
onActivityEvent?: (event: ChatActivityEvent) => void;
};
type SharedChatConnectionState = {
connectionState: ConnectionState;
connectionErrorDetail: string;
runtimeSnapshot: ChatRuntimeSnapshot | null;
};
type SharedChatConnection = SharedChatConnectionState & {
socketRef: { current: WebSocket | null };
reconnectTimerId: number | null;
disconnectUiTimerId: number | null;
connectTimeoutId: number | null;
sessionId: string;
currentContext: ChatViewContext | null;
setMessages: Dispatch<SetStateAction<ChatMessage[]>> | null;
onMessageEvent?: ((message: ChatMessage, sessionId: string) => void) | undefined;
onJobEvent?: ((event: ChatJobEvent, sessionId: string) => void) | undefined;
onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined;
onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined;
onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined;
lastEventId: number;
websocketUrl: string;
subscribers: Set<() => void>;
pingSubscriberCount: number;
consumerCount: number;
pingIntervalId: number | null;
visibilityHandlerInstalled: boolean;
pageShowHandlerInstalled: boolean;
focusHandlerInstalled: boolean;
onlineHandlerInstalled: boolean;
hasConnectedOnce: boolean;
suppressDisconnectNotification: boolean;
lastBackgroundAt: number | null;
};
const sharedChatConnection: SharedChatConnection = {
connectionState: 'connecting',
connectionErrorDetail: '',
runtimeSnapshot: null,
socketRef: { current: null },
reconnectTimerId: null,
disconnectUiTimerId: null,
connectTimeoutId: null,
sessionId: '',
currentContext: null,
setMessages: null,
onMessageEvent: undefined,
onJobEvent: undefined,
onRuntimeEvent: undefined,
onRuntimeDetailEvent: undefined,
onActivityEvent: undefined,
lastEventId: 0,
websocketUrl: '',
subscribers: new Set(),
pingSubscriberCount: 0,
consumerCount: 0,
pingIntervalId: null,
visibilityHandlerInstalled: false,
pageShowHandlerInstalled: false,
focusHandlerInstalled: false,
onlineHandlerInstalled: false,
hasConnectedOnce: false,
suppressDisconnectNotification: false,
lastBackgroundAt: null,
};
function emitSharedState() {
sharedChatConnection.subscribers.forEach((listener) => {
listener();
});
}
function getSnapshot(): SharedChatConnectionState {
return {
connectionState: sharedChatConnection.connectionState,
connectionErrorDetail: sharedChatConnection.connectionErrorDetail,
runtimeSnapshot: sharedChatConnection.runtimeSnapshot,
};
}
export function getChatConnectionSnapshot() {
return getSnapshot();
}
export function subscribeChatConnection(listener: () => void) {
sharedChatConnection.subscribers.add(listener);
return () => {
sharedChatConnection.subscribers.delete(listener);
};
}
export function getSharedChatRuntimeSnapshot() {
return sharedChatConnection.runtimeSnapshot;
}
export function setSharedChatRuntimeSnapshot(snapshot: ChatRuntimeSnapshot | null) {
if (sharedChatConnection.runtimeSnapshot === snapshot) {
return;
}
sharedChatConnection.runtimeSnapshot = snapshot;
emitSharedState();
}
function setSharedConnectionState(nextState: ConnectionState) {
if (sharedChatConnection.connectionState === nextState) {
return;
}
sharedChatConnection.connectionState = nextState;
emitSharedState();
}
function setSharedConnectionError(detail: string) {
if (sharedChatConnection.connectionErrorDetail === detail) {
return;
}
sharedChatConnection.connectionErrorDetail = detail;
emitSharedState();
}
function clearReconnectTimer() {
if (sharedChatConnection.reconnectTimerId !== null) {
window.clearTimeout(sharedChatConnection.reconnectTimerId);
sharedChatConnection.reconnectTimerId = null;
}
}
function clearDisconnectUiTimer() {
if (sharedChatConnection.disconnectUiTimerId !== null) {
window.clearTimeout(sharedChatConnection.disconnectUiTimerId);
sharedChatConnection.disconnectUiTimerId = null;
}
}
function clearConnectTimeout() {
if (sharedChatConnection.connectTimeoutId !== null) {
window.clearTimeout(sharedChatConnection.connectTimeoutId);
sharedChatConnection.connectTimeoutId = null;
}
}
function sendContextUpdate(context: ChatViewContext | null = sharedChatConnection.currentContext) {
const socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
return;
}
const liveVisibilityState =
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(
JSON.stringify({
type: 'context:update',
payload: {
pageId: context.pageId,
pageTitle: context.pageTitle,
topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState,
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
chatTypeIsTemplate: context.chatTypeIsTemplate,
},
}),
);
}
function sendPresencePing() {
const socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(
JSON.stringify({
type: 'presence:ping',
payload: {
at: Date.now(),
},
}),
);
}
function ensureSharedSocket() {
const socket = sharedChatConnection.socketRef.current;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
connectSharedSocket();
}
function sendEventReceived(eventId: number) {
const socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) {
return;
}
socket.send(
JSON.stringify({
type: 'event:received',
payload: {
eventId,
},
}),
);
}
function stopPresenceMonitoring() {
if (sharedChatConnection.pingIntervalId !== null) {
window.clearInterval(sharedChatConnection.pingIntervalId);
sharedChatConnection.pingIntervalId = null;
}
if (sharedChatConnection.visibilityHandlerInstalled) {
window.removeEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = false;
}
if (sharedChatConnection.pageShowHandlerInstalled) {
window.removeEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = false;
}
if (sharedChatConnection.focusHandlerInstalled) {
window.removeEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = false;
}
if (sharedChatConnection.onlineHandlerInstalled) {
window.removeEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = false;
}
}
function handleVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
sharedChatConnection.lastBackgroundAt = Date.now();
sendContextUpdate(sharedChatConnection.currentContext);
sendPresencePing();
return;
}
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handlePageShow() {
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowFocus() {
ensureSharedSocket();
sendPresencePing();
}
function handleWindowOnline() {
ensureSharedSocket();
}
function startPresenceMonitoring() {
if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') {
stopPresenceMonitoring();
return;
}
sharedChatConnection.lastBackgroundAt = null;
sendPresencePing();
if (sharedChatConnection.pingIntervalId === null) {
sharedChatConnection.pingIntervalId = window.setInterval(() => {
sendPresencePing();
}, PRESENCE_PING_INTERVAL_MS);
}
if (!sharedChatConnection.visibilityHandlerInstalled) {
window.addEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = true;
}
if (!sharedChatConnection.pageShowHandlerInstalled) {
window.addEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = true;
}
if (!sharedChatConnection.focusHandlerInstalled) {
window.addEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = true;
}
if (!sharedChatConnection.onlineHandlerInstalled) {
window.addEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = true;
}
}
function scheduleReconnect() {
if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) {
return;
}
sharedChatConnection.reconnectTimerId = window.setTimeout(() => {
sharedChatConnection.reconnectTimerId = null;
connectSharedSocket();
}, CHAT_CONNECTION.reconnectDelayMs);
}
function handleSharedDisconnect(message?: string, detail?: string) {
setSharedConnectionError(detail ?? '');
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('disconnected');
} else {
sharedChatConnection.disconnectUiTimerId = window.setTimeout(() => {
sharedChatConnection.disconnectUiTimerId = null;
if (sharedChatConnection.socketRef.current?.readyState === WebSocket.OPEN) {
return;
}
setSharedConnectionState('disconnected');
}, DISCONNECT_UI_DELAY_MS);
}
if (message) {
scheduleReconnect();
}
}
function disconnectSharedSocket() {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
const socket = sharedChatConnection.socketRef.current;
sharedChatConnection.suppressDisconnectNotification = true;
sharedChatConnection.socketRef.current = null;
socket?.close();
}
function releaseSharedConnectionConsumer() {
sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1);
if (sharedChatConnection.consumerCount > 0) {
return;
}
sharedChatConnection.currentContext = null;
sharedChatConnection.setMessages = null;
sharedChatConnection.onMessageEvent = undefined;
sharedChatConnection.onJobEvent = undefined;
sharedChatConnection.onRuntimeEvent = undefined;
sharedChatConnection.onRuntimeDetailEvent = undefined;
setSharedChatRuntimeSnapshot(null);
disconnectSharedSocket();
setSharedConnectionError('');
setSharedConnectionState('disconnected');
}
function connectSharedSocket() {
if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) {
return;
}
if (!hasRegisteredAccessTokenAccess()) {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
setSharedConnectionState('disconnected');
return;
}
const currentSocket = sharedChatConnection.socketRef.current;
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
return;
}
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('connecting');
}
sharedChatConnection.websocketUrl = resolveChatWebSocketUrl(sharedChatConnection.sessionId, sharedChatConnection.lastEventId);
let socket: WebSocket;
try {
socket = new WebSocket(sharedChatConnection.websocketUrl);
} catch {
handleSharedDisconnect(
`워크서버 WebSocket 주소가 올바르지 않습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.',
);
return;
}
sharedChatConnection.socketRef.current = socket;
sharedChatConnection.suppressDisconnectNotification = false;
let disconnectHandled = false;
const reportDisconnect = (message?: string, closeEvent?: CloseEvent) => {
if (disconnectHandled) {
return;
}
disconnectHandled = true;
const wasSuppressed = sharedChatConnection.suppressDisconnectNotification;
if (sharedChatConnection.socketRef.current === socket) {
sharedChatConnection.socketRef.current = null;
}
sharedChatConnection.suppressDisconnectNotification = false;
if (wasSuppressed) {
setSharedConnectionError('');
return;
}
if (closeEvent?.code === 1008) {
clearReconnectTimer();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
setSharedConnectionState('disconnected');
return;
}
if (closeEvent?.code === 1000 && !message) {
setSharedConnectionError('');
return;
}
void diagnoseConnectionFailure(sharedChatConnection.websocketUrl, closeEvent).then((detail) => {
handleSharedDisconnect(message, detail);
});
};
sharedChatConnection.connectTimeoutId = window.setTimeout(() => {
if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) {
return;
}
sharedChatConnection.socketRef.current = null;
socket.close();
reportDisconnect(
`워크서버 연결 시간이 초과되었습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
);
}, CHAT_CONNECTION.connectTimeoutMs);
socket.addEventListener('open', () => {
clearConnectTimeout();
clearDisconnectUiTimer();
sharedChatConnection.hasConnectedOnce = true;
sharedChatConnection.suppressDisconnectNotification = false;
setSharedConnectionState('connected');
setSharedConnectionError('');
sendContextUpdate(sharedChatConnection.currentContext);
startPresenceMonitoring();
});
socket.addEventListener('message', (event) => {
const setMessages = sharedChatConnection.setMessages;
if (!setMessages) {
return;
}
void handleChatServerEvent({
eventData: String(event.data),
currentPageUrl: sharedChatConnection.currentContext?.pageUrl ?? '',
expectedSessionId: sharedChatConnection.sessionId,
setMessages,
onMessageEvent: sharedChatConnection.onMessageEvent,
onJobEvent: sharedChatConnection.onJobEvent,
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
onActivityEvent: sharedChatConnection.onActivityEvent,
onEventReceived: (eventId) => {
sharedChatConnection.lastEventId = eventId;
persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId);
sendEventReceived(eventId);
},
});
try {
const parsedEvent = JSON.parse(String(event.data)) as ChatRuntimeEventEnvelope | null;
if (parsedEvent?.type === 'chat:runtime') {
setSharedChatRuntimeSnapshot(parsedEvent.payload);
}
} catch {
// ignore malformed payloads here; detailed parsing is already handled downstream
}
});
socket.addEventListener('close', (event) => {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect(
event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.',
event,
);
});
socket.addEventListener('error', () => {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.');
});
}
type ChatRuntimeEventEnvelope = {
type: 'chat:runtime';
payload: ChatRuntimeSnapshot;
};
function ensureSharedConnection(options: UseChatConnectionOptions) {
const sessionChanged = sharedChatConnection.sessionId !== options.sessionId;
sharedChatConnection.currentContext = options.currentContext;
sharedChatConnection.setMessages = options.setMessages;
sharedChatConnection.onMessageEvent = options.onMessageEvent;
sharedChatConnection.onJobEvent = options.onJobEvent;
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = options.onActivityEvent;
if (sessionChanged) {
sharedChatConnection.sessionId = options.sessionId;
sharedChatConnection.lastEventId = getLastReceivedChatEventId(options.sessionId);
sharedChatConnection.hasConnectedOnce = false;
disconnectSharedSocket();
}
connectSharedSocket();
}
export function useChatConnection({
sessionId,
currentContext,
setMessages,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
}: UseChatConnectionOptions) {
const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot());
useEffect(() => {
sharedChatConnection.consumerCount += 1;
return () => {
releaseSharedConnectionConsumer();
};
}, []);
useEffect(() => {
const handleSnapshotChange = () => {
setSnapshot(getSnapshot());
};
const unsubscribe = subscribeChatConnection(handleSnapshotChange);
ensureSharedConnection({
sessionId,
currentContext,
setMessages,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
});
handleSnapshotChange();
return () => {
unsubscribe();
};
}, [sessionId, setMessages]);
useEffect(() => {
sharedChatConnection.currentContext = currentContext;
sharedChatConnection.setMessages = setMessages;
sharedChatConnection.onMessageEvent = onMessageEvent;
sharedChatConnection.onJobEvent = onJobEvent;
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = onActivityEvent;
sendContextUpdate(currentContext);
}, [
currentContext,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
setMessages,
]);
useEffect(() => {
sharedChatConnection.pingSubscriberCount += 1;
startPresenceMonitoring();
return () => {
sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1);
if (sharedChatConnection.pingSubscriberCount === 0) {
stopPresenceMonitoring();
}
};
}, []);
return {
connectionState: snapshot.connectionState,
connectionErrorDetail: snapshot.connectionErrorDetail,
socketRef: sharedChatConnection.socketRef,
};
}