import type { Dispatch, SetStateAction } from 'react'; import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity'; import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess'; import { reportClientError } from '../errorLogApi'; import type { ChatActivityEvent, ChatConversationActivityLog, ChatConversationDetailResponse, ChatComposerAttachment, ChatConversationRequest, ChatConversationSummary, ChatJobEvent, ChatMessage, ChatRuntimeJobDetail, ChatRuntimeSnapshot, ChatServerEvent, ChatViewContext, } from './types'; const CONNECT_TIMEOUT_MS = 20000; const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id'; const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:'; const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:'; const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:'; const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024; const KST_TIME_ZONE = 'Asia/Seoul'; const chatSessionLastTypeMemory = new Map(); const chatLastEventIdMemory = new Map(); const chatOfflineNotificationMemory = new Map(); let chatClientSessionIdMemory = ''; let localMessageSequence = 0; let chatConversationListRequestPromise: Promise | null = null; export function invalidateChatConversationListCache() { chatConversationListRequestPromise = null; } function toConversationSortTime(value: string | null | undefined) { if (typeof value !== 'string' || !value.trim()) { return 0; } const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; } export function sortChatConversationSummaries(items: ChatConversationSummary[]) { return [...items].sort((left, right) => { const leftTime = Math.max( toConversationSortTime(left.lastMessageAt), toConversationSortTime(left.updatedAt), toConversationSortTime(left.createdAt), ); const rightTime = Math.max( toConversationSortTime(right.lastMessageAt), toConversationSortTime(right.updatedAt), toConversationSortTime(right.createdAt), ); if (rightTime !== leftTime) { return rightTime - leftTime; } return left.sessionId.localeCompare(right.sessionId, 'ko-KR'); }); } export const CHAT_CONNECTION = { reconnectDelayMs: 1500, connectTimeoutMs: CONNECT_TIMEOUT_MS, sessionIdKey: CHAT_SESSION_ID_KEY, lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX, notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX, sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX, introMessage: CHAT_INTRO_MESSAGE, } as const; function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | null) { const normalizedSessionId = sessionId.trim() || 'default'; const normalizedClientId = clientId?.trim() || getOrCreateClientId() || 'default-client'; return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`; } function createBrowserSessionId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } export function clearStoredChatClientConversationState() { if (typeof window === 'undefined') { return; } chatClientSessionIdMemory = ''; chatSessionLastTypeMemory.clear(); chatLastEventIdMemory.clear(); chatOfflineNotificationMemory.clear(); } function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest { const hasResponse = item.hasResponse === true; return { ...item, hasResponse, canDelete: item.canDelete === true || (!hasResponse && item.status !== 'queued' && item.status !== 'started' && item.status !== 'removed'), }; } export function isPreparingChatReplyText(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } export function getChatClientSessionId() { if (typeof window === 'undefined') { return ''; } if (chatClientSessionIdMemory) { return chatClientSessionIdMemory; } chatClientSessionIdMemory = createBrowserSessionId(); return chatClientSessionIdMemory; } export function setChatClientSessionId(sessionId: string) { if (typeof window === 'undefined') { return; } const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } chatClientSessionIdMemory = normalizedSessionId; } export function getLastReceivedChatEventId(sessionId: string) { if (typeof window === 'undefined') { return 0; } const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return 0; } return chatLastEventIdMemory.get(normalizedSessionId) ?? 0; } export function persistLastReceivedChatEventId(sessionId: string, eventId: number) { if (typeof window === 'undefined' || !Number.isFinite(eventId) || eventId <= 0) { return; } const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } const currentEventId = getLastReceivedChatEventId(normalizedSessionId); if (eventId <= currentEventId) { return; } chatLastEventIdMemory.set(normalizedSessionId, eventId); } export function resetLastReceivedChatEventId(sessionId: string) { if (typeof window === 'undefined') { return; } const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } chatLastEventIdMemory.delete(normalizedSessionId); } export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { if (typeof window === 'undefined') { return null; } const key = buildNotifyOfflineStorageKey(sessionId, clientId); if (!chatOfflineNotificationMemory.has(key)) { return null; } return chatOfflineNotificationMemory.get(key) ?? null; } export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) { if (typeof window === 'undefined') { return; } chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled); } export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) { if (typeof window === 'undefined') { return; } chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId)); } function resolveSyncedChatOfflineNotificationSetting( sessionId: string, serverValue: boolean, clientId?: string | null, ) { const storedValue = getStoredChatOfflineNotificationSetting(sessionId, clientId); if (storedValue == null) { setStoredChatOfflineNotificationSetting(sessionId, serverValue, clientId); return serverValue; } if (storedValue !== serverValue) { setStoredChatOfflineNotificationSetting(sessionId, serverValue, clientId); } return serverValue; } export function getStoredChatSessionLastTypeId(sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return null; } const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? ''; return raw || null; } export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) { const normalizedSessionId = sessionId.trim(); const normalizedChatTypeId = chatTypeId.trim(); if (!normalizedSessionId) { return; } if (!normalizedChatTypeId) { chatSessionLastTypeMemory.delete(normalizedSessionId); return; } chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId); } export function formatTime(date: Date) { return new Intl.DateTimeFormat('sv-SE', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .format(date) .replace(',', ''); } function createLocalMessageId() { localMessageSequence = (localMessageSequence + 1) % 1_000; return Date.now() * 1_000 + localMessageSequence; } function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') { const baseId = hashRequestId(requestId) * 10; if (variant === 'user') { return -(baseId + 3); } if (variant === 'activity') { return -(baseId + 2); } return -(baseId + 1); } function hashRequestId(value: string) { let hash = 0; for (const character of value) { hash = (hash * 31 + character.charCodeAt(0)) | 0; } return Math.abs(hash) + 1_000_000; } export function createLocalMessage(text: string): ChatMessage { return { id: createLocalMessageId(), author: 'system', text, timestamp: formatTime(new Date()), }; } export function createChatMessage(author: ChatMessage['author'], text: string, clientRequestId?: string | null): ChatMessage { return { id: createLocalMessageId(), author, text, timestamp: formatTime(new Date()), clientRequestId: clientRequestId?.trim() || null, }; } export function createActivityLogPlaceholder(requestId: string, lines?: string[]) { const normalizedRequestId = requestId.trim(); if (!normalizedRequestId) { return null; } const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean); const fallbackLine = '요청을 접수했습니다. 활동 로그를 준비하고 있습니다.'; return { id: hashRequestId(normalizedRequestId), author: 'system' as const, text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${(normalizedLines.length > 0 ? normalizedLines : [fallbackLine]).join('\n\n')}`, timestamp: formatTime(new Date()), clientRequestId: normalizedRequestId, }; } function isActivityLogMessage(message: ChatMessage) { return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } function extractActivityLogLines(text: string) { return text .slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length) .split('\n\n') .map((line) => line.trim()) .filter(Boolean); } function getActivityLogLines(message?: ChatMessage) { if (!message || !isActivityLogMessage(message)) { return []; } return extractActivityLogLines(message.text); } function mergeActivityLines(existingLines: string[], incomingLines: string[]) { const merged: string[] = []; for (const line of [...existingLines, ...incomingLines]) { const normalized = line.trim(); if (!normalized || merged.at(-1) === normalized) { continue; } merged.push(normalized); } return merged; } function buildActivityMessageIndex(messages: ChatMessage[]) { const indexByRequestId = new Map(); messages.forEach((message, index) => { const requestId = message.clientRequestId?.trim(); if (!requestId || !isActivityLogMessage(message)) { return; } indexByRequestId.set(requestId, index); }); return indexByRequestId; } function findActivityAnchorIndex(messages: ChatMessage[], requestId: string) { let fallbackUserIndex = -1; for (let index = 0; index < messages.length; index += 1) { const message = messages[index]; if (message.clientRequestId?.trim() !== requestId) { continue; } if (isActivityLogMessage(message)) { return index; } if (message.author === 'user') { fallbackUserIndex = index; } } return fallbackUserIndex >= 0 ? fallbackUserIndex + 1 : messages.length; } function upsertActivityLogMessage(messages: ChatMessage[], activityMessage: ChatMessage) { const requestId = activityMessage.clientRequestId?.trim(); if (!requestId) { return messages; } const existingIndex = messages.findIndex( (message) => isActivityLogMessage(message) && message.clientRequestId?.trim() === requestId, ); if (existingIndex >= 0) { const nextMessages = [...messages]; nextMessages[existingIndex] = activityMessage; return nextMessages; } const insertIndex = findActivityAnchorIndex(messages, requestId); return [...messages.slice(0, insertIndex), activityMessage, ...messages.slice(insertIndex)]; } export function hydrateActivityLogMessages(messages: ChatMessage[], activityLogs: ChatConversationActivityLog[]) { if (activityLogs.length === 0) { return messages; } let nextMessages = [...messages]; for (const activityLog of activityLogs) { const requestId = activityLog.requestId?.trim(); if (!requestId) { continue; } const placeholder = createActivityLogPlaceholder(requestId, activityLog.lines); if (!placeholder) { continue; } const existingMessage = nextMessages.find( (message) => isActivityLogMessage(message) && message.clientRequestId?.trim() === requestId, ); const mergedLines = mergeActivityLines(getActivityLogLines(existingMessage), activityLog.lines); const nextActivityMessage = { ...(existingMessage ?? placeholder), ...placeholder, text: createActivityLogPlaceholder(requestId, mergedLines)?.text ?? placeholder.text, timestamp: activityLog.updatedAt?.trim() || existingMessage?.timestamp || placeholder.timestamp, }; nextMessages = upsertActivityLogMessage(nextMessages, nextActivityMessage); } return nextMessages; } export function appendActivityEventToMessages(previous: ChatMessage[], event: ChatActivityEvent) { const requestId = event.requestId.trim(); if (!requestId) { return previous; } const activityMessageIndex = buildActivityMessageIndex(previous); const existingIndex = activityMessageIndex.get(requestId); const existingMessage = existingIndex == null ? undefined : previous[existingIndex]; const mergedLines = mergeActivityLines(getActivityLogLines(existingMessage), [event.line]); const nextMessage = createActivityLogPlaceholder(requestId, mergedLines); if (!nextMessage) { return previous; } const nextActivityMessage = existingIndex == null ? nextMessage : { ...existingMessage, ...nextMessage, }; return upsertActivityLogMessage(previous, nextActivityMessage); } export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?: string) { const normalizedChatTypeLabel = chatTypeLabel?.trim() ?? ''; const contextLabelLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' ? `선택 컨텍스트: ${normalizedChatTypeLabel}` : ''; const contextDescriptionLine = chatTypeDescription ? `기본 문맥: ${chatTypeDescription}` : ''; return createChatMessage( 'codex', [CHAT_CONNECTION.introMessage, contextLabelLine, contextDescriptionLine].filter(Boolean).join('\n'), ); } export function buildOfflineReply(context: ChatViewContext, input: string) { const normalized = input.toLowerCase(); const normalizedChatTypeLabel = context.chatTypeLabel?.trim() ?? ''; const typeLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' ? `- 컨텍스트: ${normalizedChatTypeLabel}` : ''; const descriptionLine = context.chatTypeDescription ? `- 기본 문맥: ${context.chatTypeDescription}` : ''; if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { return ['결과', `- preview: ${context.pageUrl}`, typeLine, descriptionLine].filter(Boolean).join('\n'); } if (input.includes('계획') || normalized.includes('plan')) { return ['결과', '- Plan 상세 조회는 서버 연결 후 가능합니다.', typeLine, descriptionLine].filter(Boolean).join('\n'); } return ['결과', `- 현재 화면: ${context.pageTitle}`, '- 서버 연결 후 더 정확한 정보를 줄 수 있습니다.', typeLine, descriptionLine] .filter(Boolean) .join('\n'); } export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) { const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL; const resolvedClientId = clientId || getOrCreateClientId(); const accessToken = getRegisteredAccessToken(); if (typeof window === 'undefined') { return ''; } if (configuredBaseUrl) { const normalizedUrl = new URL(configuredBaseUrl, window.location.origin); normalizedUrl.protocol = normalizedUrl.protocol === 'https:' ? 'wss:' : 'ws:'; const trimmedPathname = normalizedUrl.pathname.replace(/\/+$/, ''); normalizedUrl.pathname = trimmedPathname.endsWith('/api') ? `${trimmedPathname.slice(0, -4) || ''}/ws/chat` : `${trimmedPathname || ''}/ws/chat`; normalizedUrl.search = ''; normalizedUrl.hash = ''; if (sessionId) { normalizedUrl.searchParams.set('sessionId', sessionId); } if (resolvedClientId) { normalizedUrl.searchParams.set('clientId', resolvedClientId); } if (accessToken) { normalizedUrl.searchParams.set('accessToken', accessToken); } if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { normalizedUrl.searchParams.set('lastEventId', String(lastEventId)); } return normalizedUrl.toString(); } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = new URL(`${protocol}//${window.location.host}/ws/chat`); if (sessionId) { url.searchParams.set('sessionId', sessionId); } if (resolvedClientId) { url.searchParams.set('clientId', resolvedClientId); } if (accessToken) { url.searchParams.set('accessToken', accessToken); } if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) { url.searchParams.set('lastEventId', String(lastEventId)); } return url.toString(); } function resolveWorkServerHealthUrl() { const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL; if (typeof window === 'undefined') { return ''; } const normalizedUrl = configuredBaseUrl ? new URL(configuredBaseUrl, window.location.origin) : new URL(window.location.origin); const trimmedPathname = normalizedUrl.pathname.replace(/\/+$/, ''); normalizedUrl.pathname = trimmedPathname.endsWith('/api') ? `${trimmedPathname.slice(0, -4) || ''}/health` : `${trimmedPathname || ''}/health`; normalizedUrl.search = ''; normalizedUrl.hash = ''; return normalizedUrl.toString(); } export async function diagnoseConnectionFailure(targetUrl: string, closeEvent?: CloseEvent) { const diagnostics: string[] = []; if (typeof navigator !== 'undefined' && !navigator.onLine) { diagnostics.push('브라우저가 오프라인 상태입니다.'); } if (typeof window !== 'undefined' && window.location.protocol === 'https:' && targetUrl.startsWith('ws://')) { diagnostics.push('HTTPS 화면에서 비보안 `ws://` 연결이 차단되었을 수 있습니다.'); } if (closeEvent) { diagnostics.push(`종료 코드 ${closeEvent.code}${closeEvent.reason ? ` (${closeEvent.reason})` : ''}`); } const healthUrl = resolveWorkServerHealthUrl(); if (healthUrl) { try { const response = await fetch(healthUrl, { cache: 'no-store', }); if (response.ok) { diagnostics.push(`헬스체크 성공: ${healthUrl}`); diagnostics.push(`HTTP 서버는 응답 중입니다. WebSocket 연결 대상: ${targetUrl}`); diagnostics.push('WebSocket 업그레이드 또는 프록시 설정을 확인해 주세요.'); } else { diagnostics.push(`헬스체크 실패: ${response.status} ${response.statusText}`); } } catch (error) { diagnostics.push( `헬스체크 요청 실패: ${error instanceof Error ? error.message : '서버에 도달하지 못했습니다.'}`, ); } } return diagnostics.join(' / '); } export async function copyText(text: string) { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } if (typeof document === 'undefined') { throw new Error('클립보드를 사용할 수 없습니다.'); } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', ''); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { const copied = document.execCommand('copy'); if (!copied) { throw new Error('복사 명령이 거부되었습니다.'); } } finally { document.body.removeChild(textarea); } } export type PreviewCopyResult = 'text' | 'image' | 'url'; async function copyImagePreview(url: string): Promise { const response = await fetch(url, { cache: 'no-store', }); if (!response.ok) { throw new Error(`preview 이미지를 가져오지 못했습니다. (${response.status})`); } const imageBlob = await response.blob(); if (!imageBlob.type.startsWith('image/')) { throw new Error('이미지 preview만 이미지 자체를 복사할 수 있습니다.'); } if (typeof navigator !== 'undefined' && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') { await navigator.clipboard.write([ new ClipboardItem({ [imageBlob.type]: imageBlob, }), ]); return 'image'; } await copyText(url); return 'url'; } function canCopyPreviewBody(kind: string | null | undefined) { return !['image', 'video', 'pdf', 'file'].includes(String(kind ?? '').trim().toLowerCase()); } export async function copyPreviewContent({ kind, url, fallbackText, }: { kind: string | null | undefined; url: string; fallbackText?: string | null; }): Promise { const normalizedKind = String(kind ?? '').trim().toLowerCase(); if (normalizedKind === 'image') { return copyImagePreview(url); } const previewBody = await resolvePreviewBodyForCopy({ kind, url, fallbackText, }); await copyText(previewBody); return 'text'; } export async function resolvePreviewBodyForCopy({ kind, url, fallbackText, }: { kind: string | null | undefined; url: string; fallbackText?: string | null; }) { const normalizedFallbackText = String(fallbackText ?? ''); if (!canCopyPreviewBody(kind)) { throw new Error('이 미리보기는 본문 텍스트를 복사할 수 없습니다.'); } try { const response = await fetch(url, { cache: 'no-store', }); if (!response.ok) { throw new Error(`preview 본문을 가져오지 못했습니다. (${response.status})`); } const bodyText = await response.text(); if (bodyText.trim()) { return bodyText; } } catch (error) { if (!normalizedFallbackText.trim()) { throw error; } } if (normalizedFallbackText.trim()) { return normalizedFallbackText; } throw new Error('복사할 preview 본문이 없습니다.'); } function resolveChatApiBaseUrl() { if (import.meta.env.VITE_WORK_SERVER_URL) { return import.meta.env.VITE_WORK_SERVER_URL; } return '/api'; } async function requestChatApi(path: string, init?: RequestInit): Promise { const headers = appendClientIdHeader(init?.headers); const accessToken = getRegisteredAccessToken(); const method = init?.method?.toUpperCase() ?? 'GET'; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 8000); if (!hasRegisteredAccessTokenAccess()) { window.clearTimeout(timeoutId); throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.'); } if (accessToken && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', accessToken); } if (init?.body != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (method === 'GET') { if (!headers.has('Cache-Control')) { headers.set('Cache-Control', 'no-store, no-cache, max-age=0'); } if (!headers.has('Pragma')) { headers.set('Pragma', 'no-cache'); } } let response: Response; try { response = await fetch(`${resolveChatApiBaseUrl()}/chat${path}`, { ...init, headers, signal: controller.signal, cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined), }); } catch (error) { window.clearTimeout(timeoutId); if (error instanceof DOMException && error.name === 'AbortError') { throw new Error('채팅 서버 응답이 지연됩니다.'); } throw new Error('채팅 서버 연결에 실패했습니다.'); } window.clearTimeout(timeoutId); if (!response.ok) { const text = await response.text(); if (text.trim()) { try { const payload = JSON.parse(text) as { message?: string }; const normalizedMessage = String(payload.message ?? '').trim(); if (normalizedMessage) { throw new Error(normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage); } } catch (error) { if (error instanceof Error && error.message) { throw error; } } } throw new Error('채팅 API 요청에 실패했습니다.'); } const text = await response.text(); if (!text.trim()) { throw new Error('채팅 서버 응답이 비어 있습니다.'); } try { return JSON.parse(text) as T; } catch { throw new Error('채팅 서버 응답을 해석하지 못했습니다.'); } } async function readFileAsBase64(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error('파일 내용을 읽지 못했습니다.')); return; } const commaIndex = reader.result.indexOf(','); resolve(commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result); }; reader.onerror = () => { reject(reader.error ?? new Error('파일 내용을 읽지 못했습니다.')); }; reader.readAsDataURL(file); }); } const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record = { zip: 'application/zip', heic: 'image/heic', heif: 'image/heif', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', pdf: 'application/pdf', }; function resolveUploadMimeType(file: File) { const normalizedName = String(file.name ?? '').trim().toLowerCase(); const extension = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim() ?? '' : ''; const normalizedType = String(file.type ?? '').trim().toLowerCase(); if (normalizedType && normalizedType !== 'application/octet-stream') { return normalizedType; } if (extension && FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]) { return FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]; } return normalizedType || 'application/octet-stream'; } export async function fetchChatConversations() { if (chatConversationListRequestPromise) { return chatConversationListRequestPromise; } const clientId = getOrCreateClientId(); chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations') .then((response) => { return sortChatConversationSummaries( response.items.map((item) => ({ ...item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId), })), ); }) .finally(() => { chatConversationListRequestPromise = null; }); return chatConversationListRequestPromise; } export async function fetchChatConversationDetail( sessionId: string, options: { limit?: number; beforeMessageId?: number | null; } = {}, ) { const clientId = getOrCreateClientId(); const query = new URLSearchParams(); if (options.limit != null) { query.set('limit', String(options.limit)); } if (Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0) { query.set('beforeMessageId', String(options.beforeMessageId)); } const response = await requestChatApi( `/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`, ); const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item)); const visibleRequestIds = new Set( response.messages .map((message) => message.clientRequestId?.trim() ?? '') .filter(Boolean), ); const hydratedMessages = hydrateActivityLogMessages( response.messages, response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), ).filter( (message) => message.author !== 'system' || isActivityLogMessage(message), ); const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs); return { ...response, messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages), item: { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting( response.item.sessionId, response.item.notifyOffline, clientId, ), }, requests: normalizedRequests, }; } export async function fetchChatRuntimeSnapshot() { const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime'); return response.item; } export async function fetchChatRuntimeJobDetail(requestId: string) { const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeJobDetail }>( `/runtime/jobs/${encodeURIComponent(requestId)}`, ); return response.item; } export async function cancelChatRuntimeJob(requestId: string) { const response = await requestChatApi<{ ok: boolean; cancelled: boolean }>( `/runtime/jobs/${encodeURIComponent(requestId)}/cancel`, { method: 'POST', }, ); return response.cancelled; } export async function removeChatRuntimeJob(requestId: string) { const response = await requestChatApi<{ ok: boolean; removed: boolean }>( `/runtime/jobs/${encodeURIComponent(requestId)}/remove`, { method: 'POST', }, ); return response.removed; } export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) { const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>( `/runtime/jobs/${encodeURIComponent(requestId)}/rollback`, { method: 'POST', body: JSON.stringify({ sessionId: sessionId?.trim() || undefined, }), }, ); return response.rolledBack; } export async function uploadChatComposerFile(sessionId: string, file: File) { const normalizedSessionId = sessionId.trim(); const resolvedMimeType = resolveUploadMimeType(file); const reportUploadFailure = async (stage: string, error: Error) => { await reportClientError({ errorType: 'chat:composer-upload', errorName: error.name, errorMessage: error.message, requestMethod: 'POST', requestPath: '/api/chat/attachments', context: { stage, sessionId: normalizedSessionId || null, fileName: file.name, fileSize: file.size, fileType: file.type || null, resolvedMimeType, }, }); }; if (!normalizedSessionId) { const uploadError = new Error('채팅 세션이 준비되지 않았습니다.'); await reportUploadFailure('validate-session', uploadError); throw uploadError; } if (file.size <= 0) { const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.'); await reportUploadFailure('validate-file', uploadError); throw uploadError; } if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) { const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${file.name})`); await reportUploadFailure('validate-file', uploadError); throw uploadError; } let contentBase64 = ''; try { contentBase64 = await readFileAsBase64(file); } catch (error) { const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.'; const uploadError = new Error(`${message} (${file.name})`); uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError'; await reportUploadFailure('read-file', uploadError); throw uploadError; } try { const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', { method: 'POST', body: JSON.stringify({ sessionId: normalizedSessionId, fileName: file.name, mimeType: resolvedMimeType, contentBase64, }), }); return response.item; } catch (error) { const uploadError = error instanceof Error && error.message.trim() ? error : new Error(`${file.name} 업로드에 실패했습니다.`); await reportUploadFailure('upload-request', uploadError); throw uploadError; } } export async function createChatConversationRoom(args: { sessionId: string; title?: string; chatTypeId?: string | null; lastChatTypeId?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; }) { const clientId = getOrCreateClientId(); const notifyOffline = args.notifyOffline ?? true; const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>('/conversations', { method: 'POST', body: JSON.stringify({ sessionId: args.sessionId, title: args.title ?? '새 대화', chatTypeId: args.chatTypeId ?? null, lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null, contextLabel: args.contextLabel ?? null, contextDescription: args.contextDescription ?? null, notifyOffline, clientId, }), }); invalidateChatConversationListCache(); return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), }; } export async function renameChatConversationRoom(sessionId: string, title: string) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>( `/conversations/${encodeURIComponent(sessionId)}`, { method: 'PATCH', body: JSON.stringify({ title, }), }, ); invalidateChatConversationListCache(); return response.item; } export async function updateChatConversationRoom( sessionId: string, payload: { title?: string; chatTypeId?: string | null; lastChatTypeId?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; }, ) { const clientId = getOrCreateClientId(); const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>( `/conversations/${encodeURIComponent(sessionId)}`, { method: 'PATCH', body: JSON.stringify(payload), }, ); invalidateChatConversationListCache(); return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), }; } export async function markChatConversationResponsesRead(sessionId: string) { const response = await requestChatApi<{ ok: boolean; sessionId: string; lastReadResponseMessageId: number | null; }>(`/conversations/${encodeURIComponent(sessionId)}/read`, { method: 'POST', }); return response; } export async function deleteChatConversationRoom(sessionId: string) { const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string }>( `/conversations/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }, ); invalidateChatConversationListCache(); return response; } export async function deleteChatConversationRequest(sessionId: string, requestId: string) { const response = await requestChatApi<{ ok: boolean; deleted: boolean; sessionId: string; requestId: string }>( `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}`, { method: 'DELETE', }, ); return response; } type HandleChatServerEventOptions = { eventData: string; currentPageUrl: string; expectedSessionId?: string; setMessages: Dispatch>; onMessageEvent?: (message: ChatMessage, sessionId: string) => void; onJobEvent?: (event: ChatJobEvent, sessionId: string) => void; onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void; onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void; onActivityEvent?: (event: ChatActivityEvent) => void; onEventReceived?: (eventId: number, sessionId: string) => void; }; function areChatMessagesEquivalent(left: ChatMessage[], right: ChatMessage[]) { if (left.length !== right.length) { return false; } return left.every((message, index) => { const other = right[index]; return ( other && message.id === other.id && message.author === other.author && message.text === other.text && message.timestamp === other.timestamp ); }); } export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage) { const existingIndex = previous.findIndex( (message) => message.id === incoming.id || Boolean( incoming.clientRequestId && message.clientRequestId && incoming.author === 'user' && message.author === 'user' && incoming.clientRequestId === message.clientRequestId, ), ); if (existingIndex < 0) { return [...previous, incoming]; } const nextMessages = [...previous]; const existingMessage = nextMessages[existingIndex]; const nextText = isActivityLogMessage(existingMessage) && isActivityLogMessage(incoming) ? createActivityLogPlaceholder( incoming.clientRequestId ?? existingMessage.clientRequestId ?? '', mergeActivityLines(getActivityLogLines(existingMessage), getActivityLogLines(incoming)), )?.text ?? incoming.text : incoming.text; nextMessages[existingIndex] = { ...existingMessage, ...incoming, text: nextText, deliveryStatus: null, retryCount: 0, }; return nextMessages; } function isSameChatMessage(left: ChatMessage, right: ChatMessage) { if (left.id === right.id) { return true; } if (isActivityLogMessage(left) && isActivityLogMessage(right)) { return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId); } return Boolean( (left.author === 'user' || left.author === 'codex') && left.author === right.author && left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId, ); } function buildComparableChatMessageKey(message: ChatMessage) { if (isActivityLogMessage(message) && message.clientRequestId) { return `activity:${message.clientRequestId}`; } if (message.author === 'user' && message.clientRequestId) { return `user-request:${message.clientRequestId}`; } if (message.author === 'codex' && message.clientRequestId) { return `codex-request:${message.clientRequestId}`; } return `id:${message.id}`; } function getComparableChatMessageTime(message: ChatMessage) { const parsed = Date.parse(String(message.timestamp ?? '').trim()); return Number.isFinite(parsed) ? parsed : 0; } function buildRecoveredMessagesFromConversationDetail( requests: ChatConversationRequest[], activityLogs: ChatConversationActivityLog[], ) { const nextMessages: ChatMessage[] = []; const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item])); requests.forEach((request) => { const requestId = request.requestId.trim(); if (!requestId || request.status === 'removed') { return; } const userText = String(request.userText ?? '').trim(); const responseText = String(request.responseText ?? '').trim(); const activityLog = activityLogMap.get(requestId); if (userText) { nextMessages.push({ id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'), author: 'user', text: userText, timestamp: request.createdAt || request.updatedAt || '', clientRequestId: requestId, }); } if (responseText) { nextMessages.push({ id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'), author: 'codex', text: responseText, timestamp: request.answeredAt || request.updatedAt || request.createdAt || '', clientRequestId: requestId, }); } if (activityLog && activityLog.lines.length > 0) { nextMessages.push({ id: createRecoveredMessageId(requestId, 'activity'), author: 'system', text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${activityLog.lines.join('\n\n')}`, timestamp: request.createdAt || request.updatedAt || activityLog.updatedAt || '', clientRequestId: requestId, }); } }); return nextMessages.sort((left, right) => { const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right); if (timeDiff !== 0) { return timeDiff; } return left.id - right.id; }); } export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) { if (previous.length === 0) { return incoming; } if (areChatMessagesEquivalent(previous, incoming)) { return previous; } const previousBuckets = new Map(); previous.forEach((message) => { const key = buildComparableChatMessageKey(message); const bucket = previousBuckets.get(key); if (bucket) { bucket.push(message); return; } previousBuckets.set(key, [message]); }); const consumeExistingMessage = (target: ChatMessage) => { const key = buildComparableChatMessageKey(target); const bucket = previousBuckets.get(key); if (!bucket || bucket.length === 0) { return null; } const matchIndex = bucket.findIndex((message) => isSameChatMessage(message, target)); if (matchIndex < 0) { return null; } const [matched] = bucket.splice(matchIndex, 1); if (bucket.length === 0) { previousBuckets.delete(key); } return matched; }; const mergedServerMessages = incoming.map((serverMessage) => { const existingMessage = consumeExistingMessage(serverMessage); if (!existingMessage) { return serverMessage; } return { ...existingMessage, ...serverMessage, deliveryStatus: null, retryCount: 0, }; }); const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat(); const nextMessages = [...mergedServerMessages, ...unmatchedLocalMessages]; return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages; } export async function handleChatServerEvent({ eventData, currentPageUrl, expectedSessionId, setMessages, onMessageEvent, onJobEvent, onRuntimeEvent, onRuntimeDetailEvent, onActivityEvent, onEventReceived, }: HandleChatServerEventOptions) { try { const payload = JSON.parse(eventData) as ChatServerEvent; // Ignore late events from a previously selected conversation so they don't // overwrite or interleave with the active thread. if (expectedSessionId && payload.sessionId !== expectedSessionId) { return; } onEventReceived?.(payload.eventId, payload.sessionId); if (payload.type === 'chat:init') { setMessages((previous) => mergeRecoveredChatMessages(previous, payload.payload.messages)); return; } if (payload.type === 'chat:message') { onMessageEvent?.(payload.payload, payload.sessionId); setMessages((previous) => upsertChatMessage(previous, payload.payload)); return; } if (payload.type === 'chat:message:update') { setMessages((previous) => upsertChatMessage(previous, payload.payload)); return; } if (payload.type === 'chat:job') { onJobEvent?.(payload.payload, payload.sessionId); return; } if (payload.type === 'chat:runtime') { onRuntimeEvent?.(payload.payload); return; } if (payload.type === 'chat:runtime:detail') { onRuntimeDetailEvent?.(payload.payload); return; } if (payload.type === 'chat:activity') { onActivityEvent?.(payload.payload); setMessages((previous) => appendActivityEventToMessages(previous, payload.payload)); return; } if (payload.type === 'chat:error') { setMessages((previous) => [...previous, createLocalMessage(payload.payload.message)]); } } catch { await reportClientError({ errorType: 'chat:message-parse', errorMessage: '채팅 응답 JSON 파싱에 실패했습니다.', requestPath: currentPageUrl || null, context: { rawMessage: eventData.slice(0, 2000), }, }); setMessages((previous) => [ ...previous, createLocalMessage('채팅 응답을 해석하지 못했습니다. 연결을 다시 확인합니다.'), ]); } }