import type { Dispatch, SetStateAction } from 'react'; import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity'; import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess'; import { reportClientError } from '../errorLogApi'; import { notifyNotificationMessagesUpdated } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; import { resolveConversationUnreadMergeState, resolveStoredConversationUnreadState } from './conversationUnread'; import type { ChatActivityEvent, ChatConversationActivityLog, ChatConversationDetailResponse, ChatComposerAttachment, ChatMessagePart, ChatPromptContextRef, ChatConversationRequest, ChatConversationSummary, ChatShareRoomLinkContext, ChatSourceChangeSnapshot, ChatSourceChangeSnapshotListResponse, 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_SHARE_ACCESS_PIN_STORAGE_KEY = 'main-chat-panel:share-access-pins'; const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요. 여러 Codex를 추가한 즉시 실행은 병렬로 처리됩니다.'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]'; const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]'; const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 300 * 1024 * 1024; const KST_TIME_ZONE = 'Asia/Seoul'; const chatSessionLastTypeMemory = new Map(); const chatLastEventIdMemory = new Map(); const chatOfflineNotificationMemory = new Map(); const chatShareAccessPinMemory = new Map(); let chatClientSessionIdMemory = ''; let localMessageSequence = 0; let chatConversationListRequestPromise: Promise | null = null; export class ChatApiError extends Error { status: number; code: string | null; constructor(message: string, status = 500, code?: string | null) { super(message); this.name = 'ChatApiError'; this.status = status; this.code = code?.trim() || null; } } export function invalidateChatConversationListCache() { chatConversationListRequestPromise = null; } function normalizeRequiredText(value: string | null | undefined) { return typeof value === 'string' ? value.trim() : ''; } function normalizeOptionalText(value: string | null | undefined) { const normalized = normalizeRequiredText(value); return normalized || null; } function canUseLocalStorage() { return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; } function readStoredChatShareAccessPins() { if (!canUseLocalStorage()) { return {} as Record; } try { const rawValue = window.localStorage.getItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY); if (!rawValue) { return {} as Record; } const parsed = JSON.parse(rawValue) as Record; const nowMs = Date.now(); const nextEntries = Object.entries(parsed).flatMap(([token, value]) => { const normalizedToken = normalizeRequiredText(token); const normalizedPin = normalizeRequiredText(typeof value?.pin === 'string' ? value.pin : ''); const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null; if (!normalizedToken || !normalizedPin) { return []; } if (expiresAtMs != null && expiresAtMs <= nowMs) { return []; } return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const]; }); return Object.fromEntries(nextEntries); } catch { return {} as Record; } } function writeStoredChatShareAccessPins(entries: Record) { if (!canUseLocalStorage()) { return; } try { const normalizedEntries = Object.entries(entries).flatMap(([token, value]) => { const normalizedToken = normalizeRequiredText(token); const normalizedPin = normalizeRequiredText(value?.pin); const expiresAtMs = Number.isFinite(value?.expiresAtMs) ? Number(value.expiresAtMs) : null; if (!normalizedToken || !normalizedPin) { return []; } return [[normalizedToken, { pin: normalizedPin, expiresAtMs }] as const]; }); if (normalizedEntries.length === 0) { window.localStorage.removeItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY); return; } window.localStorage.setItem( CHAT_SHARE_ACCESS_PIN_STORAGE_KEY, JSON.stringify(Object.fromEntries(normalizedEntries)), ); } catch { // Ignore storage failures in restricted runtimes. } } function removeStoredChatShareAccessPin(token: string) { const nextEntries = readStoredChatShareAccessPins(); delete nextEntries[token]; writeStoredChatShareAccessPins(nextEntries); } export function getStoredChatShareAccessPin(token?: string | null) { const normalizedToken = normalizeRequiredText(token); if (!normalizedToken) { return ''; } const stored = chatShareAccessPinMemory.get(normalizedToken); if (!stored) { const persisted = readStoredChatShareAccessPins()[normalizedToken]; if (!persisted) { return ''; } chatShareAccessPinMemory.set(normalizedToken, persisted); return persisted.pin.trim(); } if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) { chatShareAccessPinMemory.delete(normalizedToken); removeStoredChatShareAccessPin(normalizedToken); return ''; } return stored.pin.trim(); } export function getStoredChatShareAccessPinExpiryMs(token?: string | null) { const normalizedToken = normalizeRequiredText(token); if (!normalizedToken) { return null; } const stored = chatShareAccessPinMemory.get(normalizedToken); if (!stored) { const persisted = readStoredChatShareAccessPins()[normalizedToken]; if (!persisted) { return null; } chatShareAccessPinMemory.set(normalizedToken, persisted); return persisted.expiresAtMs; } if (stored.expiresAtMs != null && stored.expiresAtMs <= Date.now()) { chatShareAccessPinMemory.delete(normalizedToken); removeStoredChatShareAccessPin(normalizedToken); return null; } return stored.expiresAtMs; } export function setStoredChatShareAccessPin( token: string, pin?: string | null, options?: { ttlMinutes?: number | null; expiresAt?: string | null; }, ) { const normalizedToken = normalizeRequiredText(token); if (!normalizedToken) { return; } const normalizedPin = normalizeRequiredText(pin); if (normalizedPin) { const expiresAt = normalizeOptionalText(options?.expiresAt); const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN; const ttlMinutes = Number.isFinite(options?.ttlMinutes) ? Math.max(0, Number(options?.ttlMinutes)) : 0; const nextEntry = { pin: normalizedPin, expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : ttlMinutes > 0 ? Date.now() + ttlMinutes * 60 * 1000 : null, }; chatShareAccessPinMemory.set(normalizedToken, nextEntry); const nextEntries = readStoredChatShareAccessPins(); nextEntries[normalizedToken] = nextEntry; writeStoredChatShareAccessPins(nextEntries); return; } chatShareAccessPinMemory.delete(normalizedToken); removeStoredChatShareAccessPin(normalizedToken); } function extractChatShareTokenFromPath(path: string) { const matched = path.match(/^\/shares\/([^/?#]+)/u); return matched?.[1] ? decodeURIComponent(matched[1]).trim() : ''; } type PromptPreview = NonNullable< NonNullable['options'][number]['preview']> >; type PromptOption = Extract['options'][number]; type PromptStep = NonNullable['steps']>[number]; function normalizePromptPreviewType(typeValue: string | null | undefined, url: string, content: string) { const normalizedType = normalizeOptionalText(typeValue).toLowerCase(); if (normalizedType === 'image' || normalizedType === 'markdown' || normalizedType === 'html' || normalizedType === 'resource') { return normalizedType; } if (normalizedType === 'md' || normalizedType === 'text' || normalizedType === 'txt' || normalizedType === 'plain') { return 'markdown'; } if (normalizedType === 'htm') { return 'html'; } const normalizedContent = normalizeOptionalText(content).trim(); const normalizedUrl = normalizeOptionalText(url).toLowerCase(); if (normalizedUrl.endsWith('.md') || normalizedUrl.endsWith('.markdown')) { return 'markdown'; } if (normalizedUrl.endsWith('.html') || normalizedUrl.endsWith('.htm')) { return 'html'; } if (!normalizedContent) { return null; } if (/<(?:!doctype\s+html|html|head|body|main|section|article|div)\b/i.test(normalizedContent)) { return 'html'; } if (/^#{1,6}\s|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s+|\[[^\]]+\]\([^)]+\)/m.test(normalizedContent)) { return 'markdown'; } return 'resource'; } function normalizePromptPreview( preview: { type?: string | null; url?: string | null; content?: string | null; alt?: string | null; title?: string | null; } | null | undefined, ): PromptPreview | null { if (!preview || typeof preview !== 'object') { return null; } const normalizedUrl = normalizeOptionalText(preview.url); const normalizedContent = normalizeOptionalText(preview.content); const type = normalizePromptPreviewType(preview.type, normalizedUrl, normalizedContent); if (!type) { return null; } return { type, url: normalizedUrl, content: normalizedContent, alt: normalizeOptionalText(preview.alt), title: normalizeOptionalText(preview.title), }; } function normalizePromptOption( option: | { value?: string | null; label?: string | null; description?: string | null; preview?: { type?: string | null; url?: string | null; content?: string | null; alt?: string | null; title?: string | null; } | null; } | null | undefined, ): PromptOption | null { if (!option || typeof option !== 'object') { return null; } const value = normalizeRequiredText(option.value); const label = normalizeRequiredText(option.label); if (!value || !label) { return null; } return { value, label, description: normalizeOptionalText(option.description), preview: normalizePromptPreview(option.preview), }; } function normalizePromptStep( step: | { key?: string | null; title?: string | null; description?: string | null; submitLabel?: string | null; mode?: 'queue' | 'direct' | null; multiple?: boolean; optional?: boolean; responseTemplate?: string | null; freeTextLabel?: string | null; freeTextPlaceholder?: string | null; selectedValues?: string[]; options?: Array<{ value?: string | null; label?: string | null; description?: string | null; preview?: { type?: string | null; url?: string | null; content?: string | null; alt?: string | null; title?: string | null; } | null; }> | null; } | null | undefined, ): PromptStep | null { if (!step || typeof step !== 'object') { return null; } const key = normalizeRequiredText(step.key); const title = normalizeRequiredText(step.title); const options = Array.isArray(step.options) ? step.options.flatMap((option) => { const normalizedOption = normalizePromptOption(option); return normalizedOption ? [normalizedOption] : []; }) : []; if (!key || !title || options.length === 0) { return null; } return { key, title, description: normalizeOptionalText(step.description), submitLabel: normalizeOptionalText(step.submitLabel), mode: step.mode === 'direct' ? 'direct' : step.mode === 'queue' ? 'queue' : null, multiple: step.multiple === true, optional: step.optional === true, responseTemplate: normalizeOptionalText(step.responseTemplate), freeTextLabel: normalizeOptionalText(step.freeTextLabel), freeTextPlaceholder: normalizeOptionalText(step.freeTextPlaceholder), selectedValues: Array.isArray(step.selectedValues) ? step.selectedValues.map((value) => normalizeRequiredText(value)).filter(Boolean) : [], options, }; } function normalizeChatMessagePart(part: ChatMessagePart | null | undefined): ChatMessagePart | null { if (!part || typeof part !== 'object') { return null; } if (part.type === 'link_card') { const title = normalizeRequiredText(part.title); const url = normalizeRequiredText(part.url); if (!title || !url) { return null; } return { type: 'link_card', title, url, actionLabel: normalizeOptionalText(part.actionLabel), } satisfies Extract; } if (part.type !== 'prompt') { return null; } const title = normalizeRequiredText(part.title); const options = Array.isArray(part.options) ? part.options.flatMap((option) => { const normalizedOption = normalizePromptOption(option); return normalizedOption ? [normalizedOption] : []; }) : []; const steps = Array.isArray(part.steps) ? part.steps.flatMap((step) => { const normalizedStep = normalizePromptStep(step); return normalizedStep ? [normalizedStep] : []; }) : []; if (!title || (options.length === 0 && steps.length === 0)) { return null; } return { type: 'prompt', title, description: normalizeOptionalText(part.description), submitLabel: normalizeOptionalText(part.submitLabel), mode: part.mode === 'direct' ? 'direct' : part.mode === 'queue' ? 'queue' : null, multiple: part.multiple === true, responseTemplate: normalizeOptionalText(part.responseTemplate), freeTextLabel: normalizeOptionalText(part.freeTextLabel), freeTextPlaceholder: normalizeOptionalText(part.freeTextPlaceholder), currentStepKey: normalizeOptionalText(part.currentStepKey), steps, readOnly: part.readOnly === true, selectedValues: Array.isArray(part.selectedValues) ? part.selectedValues.map((value) => normalizeRequiredText(value)).filter(Boolean) : [], resolvedBy: part.resolvedBy === 'user' || part.resolvedBy === 'timeout' || part.resolvedBy === 'system' ? part.resolvedBy : null, resolvedAt: normalizeOptionalText(part.resolvedAt), resultText: normalizeOptionalText(part.resultText), options, }; } function normalizeChatMessage(message: ChatMessage, fallbackIndex = 0): ChatMessage { const author = message.author === 'codex' || message.author === 'user' || message.author === 'system' ? message.author : 'system'; const normalizedId = Number.isFinite(message.id) ? Number(message.id) : -(fallbackIndex + 1); return { ...message, id: normalizedId, author, text: typeof message.text === 'string' ? message.text : '', timestamp: typeof message.timestamp === 'string' ? message.timestamp : '', clientRequestId: normalizeOptionalText(message.clientRequestId), deliveryStatus: message.deliveryStatus === 'failed' || message.deliveryStatus === 'retrying' ? message.deliveryStatus : null, retryCount: Number.isFinite(message.retryCount) ? Math.max(0, Math.round(Number(message.retryCount))) : 0, parts: Array.isArray(message.parts) ? message.parts.flatMap((part) => { const normalizedPart = normalizeChatMessagePart(part); return normalizedPart ? [normalizedPart] : []; }) : [], }; } 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; } function getConversationLastMessageSortTime(item: ChatConversationSummary) { const lastMessageTime = toConversationSortTime(item.lastMessageAt); if (lastMessageTime > 0) { return lastMessageTime; } return Math.max( toConversationSortTime(item.createdAt), toConversationSortTime(item.updatedAt), ); } function pickPreferredConversationSummary( left: ChatConversationSummary, right: ChatConversationSummary, ) { const leftTime = getConversationLastMessageSortTime(left); const rightTime = getConversationLastMessageSortTime(right); if (rightTime !== leftTime) { return rightTime > leftTime ? right : left; } const leftUpdatedAt = toConversationSortTime(left.updatedAt); const rightUpdatedAt = toConversationSortTime(right.updatedAt); if (rightUpdatedAt !== leftUpdatedAt) { return rightUpdatedAt > leftUpdatedAt ? right : left; } return right; } function mergeConversationSummaries( existing: ChatConversationSummary, incoming: ChatConversationSummary, ) { const preferred = pickPreferredConversationSummary(existing, incoming); const fallback = preferred === existing ? incoming : existing; return { ...fallback, ...preferred, clientId: preferred.clientId ?? fallback.clientId, isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly, draftText: preferred.draftText ?? fallback.draftText ?? '', title: preferred.title.trim() || fallback.title.trim(), requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null, codexModel: preferred.codexModel?.trim() || fallback.codexModel?.trim() || null, chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null, lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null, generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null, contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null, contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null, roomScope: preferred.roomScope ?? fallback.roomScope ?? null, notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline, hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming), hasPendingAttention: preferred.hasPendingAttention === true, currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null, currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus, currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null, currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0), currentStatusUpdatedAt: preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null, isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork, pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason, lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(), lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(), lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(), createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(), updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(), lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null, }; } export function sortChatConversationSummaries(items: ChatConversationSummary[]) { const dedupedItems = items.reduce((result, item) => { const sessionId = item.sessionId.trim(); if (!sessionId) { result.push(item); return result; } const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId); if (existingIndex < 0) { result.push(item); return result; } const nextItems = [...result]; nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item); return nextItems; }, []); return dedupedItems.sort((left, right) => { const leftTime = getConversationLastMessageSortTime(left); const rightTime = getConversationLastMessageSortTime(right); if (rightTime !== leftTime) { return rightTime - leftTime; } return left.sessionId.localeCompare(right.sessionId, 'ko-KR'); }); } export function getDefaultRequestStatusMessage(status: ChatConversationRequest['status']) { switch (status) { case 'accepted': return '요청을 접수했습니다.'; case 'queued': return '대기열 등록'; case 'started': return '요청 처리 중'; case 'completed': return '요청 처리 완료'; case 'failed': return '요청 처리 실패'; case 'cancelled': return '요청 실행 중단'; case 'removed': return '요청 기록이 제거되었습니다.'; default: return null; } } export function mergeConversationRequestStatusMessage( previousItem: Pick | null | undefined, nextItem: Pick, ) { const nextStatusMessage = nextItem.statusMessage?.trim() || ''; if (nextStatusMessage) { return nextStatusMessage; } if (!previousItem || previousItem.status !== nextItem.status) { return getDefaultRequestStatusMessage(nextItem.status); } return previousItem.statusMessage?.trim() || getDefaultRequestStatusMessage(nextItem.status); } 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; const usageSnapshot = item.usageSnapshot && typeof item.usageSnapshot === 'object' ? { tokenTotals: { total: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.total ?? 0) || 0)), input: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.input ?? 0) || 0)), output: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.output ?? 0) || 0)), cached: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.cached ?? 0) || 0)), reasoning: Math.max(0, Math.round(Number(item.usageSnapshot.tokenTotals?.reasoning ?? 0) || 0)), }, totalTokens: Math.max(0, Math.round(Number(item.usageSnapshot.totalTokens ?? 0) || 0)), } : null; const promptContextRef = item.promptContextRef?.key === 'prompt_parent_question' && normalizeRequiredText(item.promptContextRef.promptTitle) ? { key: 'prompt_parent_question' as const, promptTitle: normalizeRequiredText(item.promptContextRef.promptTitle), promptDescription: normalizeOptionalText(item.promptContextRef.promptDescription), parentQuestionText: normalizeOptionalText(item.promptContextRef.parentQuestionText), } : null; return { ...item, sessionId: normalizeRequiredText(item.sessionId), requestId: normalizeRequiredText(item.requestId), requesterClientId: normalizeOptionalText(item.requesterClientId), chatTypeId: normalizeOptionalText(item.chatTypeId), chatTypeLabel: normalizeRequiredText(item.chatTypeLabel), parentRequestId: normalizeOptionalText(item.parentRequestId), promptContextRef, statusMessage: normalizeOptionalText(item.statusMessage), retryCount: Number.isFinite(Number(item.retryCount)) ? Math.max(0, Math.round(Number(item.retryCount))) : 0, userText: normalizeRequiredText(item.userText), responseText: normalizeRequiredText(item.responseText), usageSnapshot, totalTokens: item.totalTokens == null ? null : Math.max(0, Math.round(Number(item.totalTokens) || 0)), hasResponse, canDelete: item.canDelete === true || (!hasResponse && item.status !== 'queued' && item.status !== 'started' && item.status !== 'removed'), manualPromptCompletedAt: normalizeOptionalText(item.manualPromptCompletedAt), manualVerificationCompletedAt: normalizeOptionalText(item.manualVerificationCompletedAt), createdAt: normalizeRequiredText(item.createdAt), updatedAt: normalizeRequiredText(item.updatedAt), answeredAt: normalizeOptionalText(item.answeredAt), terminalAt: normalizeOptionalText(item.terminalAt), }; } export function isPreparingChatReplyText(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } function shouldPreserveExistingCodexMessageText(existingMessage: ChatMessage, incomingMessage: ChatMessage) { if (existingMessage.author !== 'codex' || incomingMessage.author !== 'codex') { return false; } const existingText = String(existingMessage.text ?? '').trim(); const incomingText = String(incomingMessage.text ?? '').trim(); if (!existingText || !incomingText) { return false; } return !isPreparingChatReplyText(existingText) && isPreparingChatReplyText(incomingText); } 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' | 'missing-request' | 'execution-failure', ) { const baseId = hashRequestId(requestId) * 10; if (variant === 'user') { return -(baseId + 3); } if (variant === 'missing-request') { return -(baseId + 2); } if (variant === 'activity') { return -(baseId + 1); } if (variant === 'execution-failure') { return -(baseId + 4); } return -(baseId + 5); } 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`); } export function isMissingRequestMessage(message: ChatMessage) { return message.author === 'system' && message.text.startsWith(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`); } export function isExecutionFailureMessage(message: ChatMessage) { return message.author === 'system' && message.text.startsWith(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`); } function isEmptyCodexExecutionResponse(text: string) { const normalized = text.replace(/\s+/g, ' ').trim(); return normalized === 'Codex 실행 결과가 비어 있습니다.'; } function extractActivityLogFailureReason(lines?: string[] | null) { const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean); for (let index = normalizedLines.length - 1; index >= 0; index -= 1) { const line = normalizedLines[index]; if (!line.startsWith('# 오류:')) { continue; } const raw = line.slice('# 오류:'.length).trim(); if (!raw) { continue; } try { const parsed = JSON.parse(raw) as { message?: unknown }; const message = typeof parsed.message === 'string' ? parsed.message.trim() : ''; if (message) { return message; } } catch { return raw; } } return ''; } function buildExecutionFailureMessage(reason: string) { const normalizedReason = reason.trim(); if (!normalizedReason) { return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n실행 중 오류가 발생했습니다.`; } const simplifiedReason = normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads') ? `세션 리소스 업로드 폴더를 만들 권한이 없어 응답 생성이 중단되었습니다.\n\n원인: ${normalizedReason}` : `실행 중 오류가 발생했습니다.\n\n원인: ${normalizedReason}`; return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n${simplifiedReason}`; } function buildFailurePreviewText(reason: string) { const normalizedReason = reason.trim(); if (!normalizedReason) { return '실행 실패'; } if (normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')) { return '실행 실패: 세션 리소스 업로드 폴더 권한 오류'; } return `실행 실패: ${normalizedReason}`; } function enrichFailedRequestsWithActivityLogs( requests: ChatConversationRequest[], activityLogs: ChatConversationActivityLog[], ) { const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item])); return requests.map((request) => { if (request.status !== 'failed') { return request; } const activityLog = activityLogMap.get(request.requestId.trim()); const failureReason = extractActivityLogFailureReason(activityLog?.lines); const normalizedStatusMessage = String(request.statusMessage ?? '').trim(); if (!failureReason) { return request; } if (normalizedStatusMessage && normalizedStatusMessage !== '요청 처리 실패') { return request; } return { ...request, statusMessage: failureReason, }; }); } function replaceGenericFailureMessages( messages: ChatMessage[], requests: ChatConversationRequest[], activityLogs: ChatConversationActivityLog[], ): ChatMessage[] { const requestMap = new Map(requests.map((item) => [item.requestId.trim(), item])); const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item])); return messages.map((message) => { const requestId = message.clientRequestId?.trim() ?? ''; if (!requestId || message.author !== 'codex' || !isEmptyCodexExecutionResponse(message.text)) { return message; } const request = requestMap.get(requestId); if (request?.status !== 'failed') { return message; } const failureReason = extractActivityLogFailureReason(activityLogMap.get(requestId)?.lines); if (!failureReason) { return message; } return { ...message, author: 'system' as const, text: buildExecutionFailureMessage(failureReason), }; }); } function resolveConversationFailurePreview( currentPreview: string, requests: ChatConversationRequest[], activityLogs: ChatConversationActivityLog[], ) { if (!isEmptyCodexExecutionResponse(currentPreview)) { return currentPreview; } const latestFailedRequest = [...requests] .reverse() .find((request) => request.status === 'failed' && isEmptyCodexExecutionResponse(String(request.responseText ?? '').trim())); if (!latestFailedRequest) { return currentPreview; } const activityLog = activityLogs.find((item) => item.requestId.trim() === latestFailedRequest.requestId.trim()); const failureReason = extractActivityLogFailureReason(activityLog?.lines); if (!failureReason) { return currentPreview; } return buildFailurePreviewText(failureReason); } 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 mergeActivityLineAtPosition(existingLines: string[], incomingLine: string, lineNo?: number) { const normalizedLine = incomingLine.trim(); if (!normalizedLine) { return existingLines; } if (!Number.isInteger(lineNo) || Number(lineNo) <= 0) { return mergeActivityLines(existingLines, [normalizedLine]); } const nextLines = [...existingLines]; nextLines[Number(lineNo) - 1] = normalizedLine; return nextLines.filter(Boolean); } 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 = mergeActivityLineAtPosition(getActivityLogLines(existingMessage), event.line, event.lineNo); 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 !== '기본처리' ? `선택 컨텍스트: ${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 !== '기본처리' ? `- 컨텍스트: ${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, shareToken?: string) { const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL; const resolvedClientId = clientId || getOrCreateClientId(); const accessToken = getRegisteredAccessToken(); const normalizedShareToken = shareToken?.trim() || ''; const storedSharePin = normalizedShareToken ? getStoredChatShareAccessPin(normalizedShareToken) : ''; 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 (normalizedShareToken) { normalizedUrl.searchParams.set('shareToken', normalizedShareToken); } if (storedSharePin) { normalizedUrl.searchParams.set('sharePin', storedSharePin); } 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 (normalizedShareToken) { url.searchParams.set('shareToken', normalizedShareToken); } if (storedSharePin) { url.searchParams.set('sharePin', storedSharePin); } 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) { return copyTextToClipboard(text); } export type PreviewShareResult = 'shared' | 'copied'; export async function sharePreviewLink({ url, title, text, }: { url: string; title?: string | null; text?: string | null; }): Promise { const normalizedUrl = String(url ?? '').trim(); if (!normalizedUrl) { throw new Error('공유할 preview 링크가 없습니다.'); } if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { try { await navigator.share({ url: normalizedUrl, title: title?.trim() || undefined, text: text?.trim() || undefined, }); return 'shared'; } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw error; } } } await copyText(normalizedUrl); return 'copied'; } 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, options?: { allowUnauthenticated?: boolean; signal?: AbortSignal; timeoutMs?: number; sharePin?: string | null; }, ): Promise { const allowUnauthenticated = options?.allowUnauthenticated === true; const headers = appendClientIdHeader(init?.headers); const accessToken = getRegisteredAccessToken(); const method = init?.method?.toUpperCase() ?? 'GET'; const controller = new AbortController(); const externalAbortHandler = () => controller.abort(); const timeoutMs = Number.isFinite(options?.timeoutMs) ? Math.max(1000, Number(options?.timeoutMs)) : 8000; const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); if (options?.signal) { if (options.signal.aborted) { window.clearTimeout(timeoutId); throw new DOMException('Aborted', 'AbortError'); } options.signal.addEventListener('abort', externalAbortHandler, { once: true }); } if (!allowUnauthenticated && !hasRegisteredAccessTokenAccess()) { window.clearTimeout(timeoutId); throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.'); } if (accessToken && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', accessToken); } if (allowUnauthenticated && !headers.has('X-Chat-Share-Pin')) { const explicitSharePin = normalizeRequiredText(options?.sharePin); const storedSharePin = explicitSharePin ? '' : getStoredChatShareAccessPin(extractChatShareTokenFromPath(path)); const resolvedSharePin = explicitSharePin || storedSharePin; if (resolvedSharePin) { headers.set('X-Chat-Share-Pin', resolvedSharePin); } } 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 (options?.signal) { options.signal.removeEventListener('abort', externalAbortHandler); } if (error instanceof DOMException && error.name === 'AbortError') { if (options?.signal?.aborted) { throw error; } throw new Error('채팅 서버 응답이 지연됩니다.'); } throw new Error('채팅 서버 연결에 실패했습니다.'); } window.clearTimeout(timeoutId); if (options?.signal) { options.signal.removeEventListener('abort', externalAbortHandler); } if (!response.ok) { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const text = await response.text(); if (response.status === 413) { throw new ChatApiError( '첨부 업로드 크기가 현재 허용 한도를 초과했습니다. 300MB 이하 파일로 다시 시도해 주세요.', response.status, ); } if (contentType.includes('text/html') && text.trim().startsWith('<')) { throw new ChatApiError('채팅 API가 HTML 오류 페이지를 반환했습니다. 프록시 업로드 한도를 확인해 주세요.', response.status); } if (text.trim()) { try { const payload = JSON.parse(text) as { message?: string; code?: string }; const normalizedMessage = String(payload.message ?? '').trim(); if (normalizedMessage) { throw new ChatApiError( normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage, response.status, typeof payload.code === 'string' ? payload.code : null, ); } } catch (error) { if (error instanceof Error && error.message) { throw error; } } } throw new ChatApiError('채팅 API 요청에 실패했습니다.', response.status); } const text = await response.text(); if (!text.trim()) { throw new Error('채팅 서버 응답이 비어 있습니다.'); } try { return JSON.parse(text) as T; } catch { throw new Error('채팅 서버 응답을 해석하지 못했습니다.'); } } function encodeChatAttachmentHeaderValue(value: string) { return encodeURIComponent(value); } 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', }; const FALLBACK_UPLOAD_EXTENSION_BY_MIME: Record = { 'application/octet-stream': 'bin', 'application/pdf': 'pdf', 'application/zip': 'zip', 'image/bmp': 'bmp', 'image/gif': 'gif', 'image/heic': 'heic', 'image/heif': 'heif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/tiff': 'tiff', 'image/webp': 'webp', }; 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'; } function resolveUploadFileName(file: File) { const normalizedName = String(file.name ?? '').trim(); if (normalizedName) { return normalizedName; } const resolvedMimeType = resolveUploadMimeType(file); const extension = FALLBACK_UPLOAD_EXTENSION_BY_MIME[resolvedMimeType] ?? 'bin'; const baseName = resolvedMimeType.startsWith('image/') ? 'pasted-image' : 'attachment'; return `${baseName}-${Date.now().toString(36)}.${extension}`; } export async function fetchChatConversations() { if (chatConversationListRequestPromise) { return chatConversationListRequestPromise; } const clientId = getOrCreateClientId(); chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>( '/conversations?limit=200', ) .then((response) => { return sortChatConversationSummaries( response.items.map((item) => ({ ...item, hasUnreadResponse: resolveStoredConversationUnreadState(item), hasPendingAttention: item.hasPendingAttention === true, 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 normalizedMessages = Array.isArray(response.messages) ? response.messages.map((message, index) => normalizeChatMessage(message, index)) : []; const normalizedRequests = enrichFailedRequestsWithActivityLogs( response.requests.map((item) => normalizeChatConversationRequest(item)), response.activityLogs, ); const visibleRequestIds = new Set( normalizedMessages .map((message) => message.clientRequestId?.trim() ?? '') .filter(Boolean), ); const hydratedMessages = hydrateActivityLogMessages( replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs), response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), ).filter( (message) => message.author !== 'system' || isActivityLogMessage(message) || isMissingRequestMessage(message) || isExecutionFailureMessage(message), ); const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs); return { ...response, messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages), item: { ...response.item, lastMessagePreview: resolveConversationFailurePreview( response.item.lastMessagePreview, normalizedRequests, response.activityLogs, ), notifyOffline: resolveSyncedChatOfflineNotificationSetting( response.item.sessionId, response.item.notifyOffline, clientId, ), }, requests: normalizedRequests, }; } export async function fetchChatShareConversationDetail( token: string, options: { sessionId: string; limit?: number; beforeMessageId?: number | null; sharePin?: string | null; }, ) { 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( `/shares/${encodeURIComponent(token)}/conversations/${encodeURIComponent(options.sessionId)}${query.toString() ? `?${query.toString()}` : ''}`, undefined, { allowUnauthenticated: true, sharePin: options.sharePin, timeoutMs: 20000, }, ); const normalizedMessages = Array.isArray(response.messages) ? response.messages.map((message, index) => normalizeChatMessage(message, index)) : []; const normalizedRequests = enrichFailedRequestsWithActivityLogs( response.requests.map((item) => normalizeChatConversationRequest(item)), response.activityLogs, ); const visibleRequestIds = new Set( normalizedMessages .map((message) => message.clientRequestId?.trim() ?? '') .filter(Boolean), ); const hydratedMessages = hydrateActivityLogMessages( replaceGenericFailureMessages(normalizedMessages, normalizedRequests, response.activityLogs), response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')), ).filter( (message) => message.author !== 'system' || isActivityLogMessage(message) || isMissingRequestMessage(message) || isExecutionFailureMessage(message), ); const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs); return { ...response, messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages), item: { ...response.item, lastMessagePreview: resolveConversationFailurePreview( response.item.lastMessagePreview, normalizedRequests, response.activityLogs, ), notifyOffline: resolveSyncedChatOfflineNotificationSetting( response.item.sessionId, response.item.notifyOffline, getOrCreateClientId(), ), }, requests: normalizedRequests, }; } export async function fetchChatRuntimeSnapshot() { const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>('/runtime'); return response.item; } export async function fetchChatShareRuntimeSnapshot( token: string, options?: { sessionId?: string | null; sharePin?: string | null; }, ) { const query = new URLSearchParams(); const normalizedSessionId = options?.sessionId?.trim() || ''; if (normalizedSessionId) { query.set('sessionId', normalizedSessionId); } const response = await requestChatApi<{ ok: boolean; item: ChatRuntimeSnapshot }>( `/shares/${encodeURIComponent(token)}/runtime${query.size > 0 ? `?${query.toString()}` : ''}`, undefined, { allowUnauthenticated: true, sharePin: options?.sharePin, timeoutMs: 20000, }, ); return response.item; } export async function cancelChatShareRuntimeRequest( token: string, payload: { requestId: string; sessionId?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; action: 'cancelled' | 'removed' }>( `/shares/${encodeURIComponent(token)}/runtime-requests/${encodeURIComponent(payload.requestId)}/cancel`, { method: 'POST', body: JSON.stringify({ sessionId: payload.sessionId?.trim() || undefined, }), }, { allowUnauthenticated: true, }, ); return response.action; } export async function fetchChatSourceChanges(limit = 300) { const query = new URLSearchParams(); query.set('limit', String(Math.max(1, Math.min(500, Math.round(limit))))); const response = await requestChatApi(`/source-changes?${query.toString()}`); const normalizedItems = Array.isArray(response.items) ? response.items.flatMap((item) => { try { return [normalizeChatSourceChangeSnapshot(item)]; } catch { return []; } }) : []; if (normalizedItems.length === 0 && Array.isArray(response.items) && response.items.length > 0) { throw new Error('Codex Live 변경 이력을 읽지 못했습니다.'); } return normalizedItems; } 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; } function normalizeChatSourceChangeSnapshot(item: ChatSourceChangeSnapshot): ChatSourceChangeSnapshot { return { ...item, clientId: item.clientId?.trim() || null, conversationTitle: item.conversationTitle.trim() || '새 대화', chatTypeId: item.chatTypeId?.trim() || null, chatTypeLabel: item.chatTypeLabel.trim(), requestId: item.requestId.trim(), requestTitle: item.requestTitle.trim() || item.requestId.trim(), questionText: item.questionText, answerText: item.answerText, status: item.status, sourceChangedAt: item.sourceChangedAt, updatedAt: item.updatedAt, featureTags: item.featureTags.map((value) => value.trim()).filter(Boolean), changedFiles: item.changedFiles.map((value) => value.trim()).filter(Boolean), currentSourceFiles: item.currentSourceFiles.map((value) => value.trim()).filter(Boolean), diffBlocks: item.diffBlocks.map((value) => value.trim()).filter(Boolean), hasSourceChanges: item.hasSourceChanges === true, reviewStatus: item.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed', sourceChangeKind: item.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request', sourceEntryIds: (Array.isArray(item.sourceEntryIds) ? item.sourceEntryIds : []).map((value) => String(value).trim()).filter(Boolean), conversationDeletedAt: item.conversationDeletedAt?.trim() || null, }; } async function uploadChatAttachmentBinary( path: string, file: File, args: { sessionId: string; fileName: string; mimeType: string; allowUnauthenticated?: boolean; sharePin?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>( path, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-Chat-Attachment-Session-Id': encodeChatAttachmentHeaderValue(args.sessionId), 'X-Chat-Attachment-File-Name': encodeChatAttachmentHeaderValue(args.fileName), 'X-Chat-Attachment-Mime-Type': encodeChatAttachmentHeaderValue(args.mimeType), }, body: file, }, { allowUnauthenticated: args.allowUnauthenticated, sharePin: args.sharePin, }, ); return response.item; } export async function uploadChatComposerFile(sessionId: string, file: File) { const normalizedSessionId = sessionId.trim(); const resolvedMimeType = resolveUploadMimeType(file); const resolvedFileName = resolveUploadFileName(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: resolvedFileName, 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(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); await reportUploadFailure('validate-file', uploadError); throw uploadError; } try { return await uploadChatAttachmentBinary('/attachments', file, { sessionId: normalizedSessionId, fileName: resolvedFileName, mimeType: resolvedMimeType, }); } catch (error) { const uploadError = error instanceof Error && error.message.trim() ? error : new Error(`${resolvedFileName} 업로드에 실패했습니다.`); await reportUploadFailure('upload-request', uploadError); throw uploadError; } } export async function uploadChatShareComposerFile(token: string, sessionId: string, file: File) { const normalizedToken = token.trim(); const normalizedSessionId = sessionId.trim(); const resolvedMimeType = resolveUploadMimeType(file); const resolvedFileName = resolveUploadFileName(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/shares/${normalizedToken ? ':token' : ''}/attachments`, context: { stage, shareTokenPresent: Boolean(normalizedToken), sessionId: normalizedSessionId || null, fileName: resolvedFileName, fileSize: file.size, fileType: file.type || null, resolvedMimeType, }, }); }; if (!normalizedToken) { const uploadError = new Error('공유 링크 토큰이 준비되지 않았습니다.'); await reportUploadFailure('validate-token', uploadError); throw uploadError; } 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(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); await reportUploadFailure('validate-file', uploadError); throw uploadError; } try { return await uploadChatAttachmentBinary( `/shares/${encodeURIComponent(normalizedToken)}/attachments`, file, { sessionId: normalizedSessionId, fileName: resolvedFileName, mimeType: resolvedMimeType, allowUnauthenticated: true, }, ); } catch (error) { const uploadError = error instanceof Error && error.message.trim() ? error : new Error(`${resolvedFileName} 업로드에 실패했습니다.`); await reportUploadFailure('upload-request', uploadError); throw uploadError; } } export async function createChatConversationRoom(args: { sessionId: string; title?: string; draftText?: string | null; requestBadgeLabel?: string | null; codexModel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; generalSectionName?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; roomScope?: Record | null; }) { 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 ?? '새 대화', draftText: args.draftText ?? '', requestBadgeLabel: args.requestBadgeLabel ?? null, codexModel: args.codexModel ?? null, chatTypeId: args.chatTypeId ?? null, lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null, generalSectionName: args.generalSectionName ?? null, contextLabel: args.contextLabel ?? null, contextDescription: args.contextDescription ?? null, roomScope: args.roomScope ?? null, notifyOffline, clientId, }), }); invalidateChatConversationListCache(); return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), }; } export async function updateChatConversationRoom( sessionId: string, payload: { title?: string | null; draftText?: string | null; requestBadgeLabel?: string | null; codexModel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; generalSectionName?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; roomScope?: Record | null; }, ) { 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 clearChatConversationRoom(sessionId: string) { const clientId = getOrCreateClientId(); const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>( `/conversations/${encodeURIComponent(sessionId)}/clear`, { method: 'POST', }, ); invalidateChatConversationListCache(); return { ...response.item, notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId), }; } export async function clearChatShareConversationRoom(token: string, sessionId?: string | null) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationSummary }>( `/shares/${encodeURIComponent(token)}/clear`, { method: 'POST', body: JSON.stringify({ sessionId: sessionId?.trim() || undefined, }), }, { allowUnauthenticated: true, }, ); return response.item; } export async function createChatShareRoom( token: string, payload: { chatTypeId: string; chatTypeLabel: string; title: string; requestBadgeLabel?: string | null; seedMessage: string; linkedSessionId?: string | null; linkedRequestId?: string | null; linkedTitle?: string | null; linkedRequestPreview?: string | null; linkedChatTypeLabel?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; room: ChatShareRoomSummary }>( `/shares/${encodeURIComponent(token)}/rooms`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); return { sessionId: normalizeRequiredText(response.room.sessionId), requestId: normalizeRequiredText(response.room.requestId), isDefault: response.room.isDefault === true, sortOrder: Number.isFinite(response.room.sortOrder) ? Number(response.room.sortOrder) : 0, title: normalizeRequiredText(response.room.title) || '공유 채팅방', requestBadgeLabel: normalizeOptionalText(response.room.requestBadgeLabel), chatTypeId: normalizeOptionalText(response.room.chatTypeId), lastChatTypeId: normalizeOptionalText(response.room.lastChatTypeId), contextLabel: normalizeOptionalText(response.room.contextLabel), contextDescription: normalizeOptionalText(response.room.contextDescription), notifyOffline: response.room.notifyOffline === true, linkContext: response.room.linkContext?.kind === 'linked-session' ? { kind: 'linked-session', sourceSessionId: normalizeRequiredText(response.room.linkContext.sourceSessionId), sourceRequestId: normalizeRequiredText(response.room.linkContext.sourceRequestId), sourceTitle: normalizeOptionalText(response.room.linkContext.sourceTitle), sourceRequestPreview: normalizeOptionalText(response.room.linkContext.sourceRequestPreview), sourceChatTypeLabel: normalizeOptionalText(response.room.linkContext.sourceChatTypeLabel), linkedAt: normalizeOptionalText(response.room.linkContext.linkedAt), } : null, createdAt: normalizeOptionalText(response.room.createdAt), updatedAt: normalizeOptionalText(response.room.updatedAt), } satisfies ChatShareRoomSummary; } export async function deleteChatShareRoom(token: string, sessionId: string) { const response = await requestChatApi<{ ok: boolean; deleted: boolean; deletedSessionId: string; nextRoomSessionId?: string | null; }>( `/shares/${encodeURIComponent(token)}/rooms/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }, { allowUnauthenticated: true, }, ); return { deleted: response.deleted === true, deletedSessionId: normalizeRequiredText(response.deletedSessionId), nextRoomSessionId: normalizeOptionalText(response.nextRoomSessionId), }; } 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; } export async function completeChatConversationRequestManualBadge( sessionId: string, requestId: string, type: 'prompt' | 'verification', ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>( `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}/manual-completion`, { method: 'POST', body: JSON.stringify({ type }), }, ); return normalizeChatConversationRequest(response.item); } export async function persistChatPromptSelection( sessionId: string, payload: { parentRequestId: string; sessionId?: string | null; promptIndex: number; promptTitle: string; promptSignature: string; sourceMessageId: number; selectedValues: string[]; freeText?: string | null; stepSelections?: Array<{ stepKey: string; stepTitle: string; selectedValues: string[]; freeText: string; skipped?: boolean; }>; summaryText?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage }>( `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(payload.parentRequestId)}/prompt-selection`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); return { item: normalizeChatConversationRequest(response.item), message: { ...normalizeChatMessage(response.message), }, }; } export async function submitChatPromptSelection( sessionId: string, payload: { parentRequestId: string; promptIndex: number; promptTitle: string; promptSignature: string; sourceMessageId: number; selectedValues: string[]; freeText?: string | null; stepSelections?: Array<{ stepKey: string; stepTitle: string; selectedValues: string[]; freeText: string; skipped?: boolean; }>; summaryText?: string | null; attachments?: ChatComposerAttachment[]; followupText: string; mode?: 'queue' | 'direct'; contextRef?: ChatPromptContextRef | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage; queuedRequestId: string }>( `/conversations/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(payload.parentRequestId)}/prompt-selection/submit`, { method: 'POST', body: JSON.stringify(payload), }, ); return { queuedRequestId: response.queuedRequestId, item: normalizeChatConversationRequest(response.item), message: { ...normalizeChatMessage(response.message), }, }; } export type ChatShareKind = 'request-bundle' | 'inquiry-message' | 'prompt'; export type ChatShareRoomSummary = { sessionId: string; requestId: string; isDefault: boolean; sortOrder: number; title: string; requestBadgeLabel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; linkContext?: ChatShareRoomLinkContext | null; createdAt?: string | null; updatedAt?: string | null; }; export type ChatShareSnapshot = { detailLevel?: 'full' | 'initial'; share: { kind: ChatShareKind; sessionId: string; requestId: string; sharePath: string; createdAt?: string | null; expiresAt?: string | null; tokenSetting: { id: string; name: string; defaultExpiresInMinutes: number; maxTokensPer30Days: number; maxTokensPer7Days: number; maxTokensPer5Hours: number; oneTimeTokenLimit: number; allowedAppIds: string[]; } | null; managedResourceTokenId?: string | null; permissions?: Array<'view' | 'download' | 'comment' | 'upload' | 'manage'>; hasAccessPin?: boolean; accessPinPromptTtlMinutes?: number | null; accessPinSessionExpiresAt?: string | null; canSendMessage?: boolean; blockedReason?: string | null; }; conversation: { sessionId: string; title: string; requestBadgeLabel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; }; rootRequestId: string; targetRequest: ChatConversationRequest; requests: ChatConversationRequest[]; messages: ChatMessage[]; activityLogs: ChatConversationActivityLog[]; rooms: ChatShareRoomSummary[]; activeSessionId?: string | null; roomRequestCounts?: { processingCount: number; unansweredCount: number; }; oldestLoadedMessageId?: number | null; hasOlderMessages?: boolean; promptTarget?: { sourceMessageId: number; promptIndex: number; prompt: Extract; } | null; refreshedAt: string; }; export type ManagedChatShareRoomDraft = { tokenSettingId: string; chatTypeId: string; chatTypeLabel: string; name: string; requestBadgeLabel?: string | null; seedMessage: string; allowManageAccess?: boolean; }; export type ManagedChatShareRoom = { sessionId: string; requestId: string; token: string; sharePath: string; managedResourceTokenId?: string | null; name: string; requestBadgeLabel?: string | null; seedMessage: string; permissions: Array<'view' | 'download' | 'comment' | 'upload' | 'manage'>; hasAccessPin?: boolean; tokenSetting: NonNullable; }; export async function createChatShareLink(payload: { kind: ChatShareKind; sessionId: string; requestId: string; name: string; sourceMessageId?: number; promptIndex?: number; promptSignature?: string; tokenSettingId: string; }) { const response = await requestChatApi<{ ok: boolean; token: string; sharePath: string; name: string; tokenSetting: NonNullable; managedResourceTokenId?: string | null; }>( '/shares', { method: 'POST', body: JSON.stringify({ ...payload, title: payload.name, tokenName: payload.name, }), }, ); return response; } export async function createManagedChatShareRoom(payload: ManagedChatShareRoomDraft) { const response = await requestChatApi<{ ok: boolean; sessionId: string; requestId: string; token: string; sharePath: string; managedResourceTokenId?: string | null; name: string; requestBadgeLabel?: string | null; seedMessage: string; permissions: ManagedChatShareRoom['permissions']; hasAccessPin?: boolean; tokenSetting: NonNullable; }>( '/shares/rooms', { method: 'POST', body: JSON.stringify({ ...payload, title: payload.name, tokenName: payload.name, }), }, ); return { ...response, managedResourceTokenId: normalizeOptionalText(response.managedResourceTokenId), requestBadgeLabel: normalizeOptionalText(response.requestBadgeLabel), permissions: Array.isArray(response.permissions) ? response.permissions.filter( (item): item is 'view' | 'download' | 'comment' | 'upload' | 'manage' => item === 'view' || item === 'download' || item === 'comment' || item === 'upload' || item === 'manage', ) : [], hasAccessPin: response.hasAccessPin === true, tokenSetting: { id: normalizeRequiredText(response.tokenSetting.id), name: normalizeRequiredText(response.tokenSetting.name), defaultExpiresInMinutes: Number.isFinite(response.tokenSetting.defaultExpiresInMinutes) ? Number(response.tokenSetting.defaultExpiresInMinutes) : 0, maxTokensPer30Days: Number.isFinite(response.tokenSetting.maxTokensPer30Days) ? Number(response.tokenSetting.maxTokensPer30Days) : 0, maxTokensPer7Days: Number.isFinite(response.tokenSetting.maxTokensPer7Days) ? Number(response.tokenSetting.maxTokensPer7Days) : 0, maxTokensPer5Hours: Number.isFinite(response.tokenSetting.maxTokensPer5Hours) ? Number(response.tokenSetting.maxTokensPer5Hours) : 0, oneTimeTokenLimit: Number.isFinite(response.tokenSetting.oneTimeTokenLimit) ? Number(response.tokenSetting.oneTimeTokenLimit) : 0, allowedAppIds: Array.isArray(response.tokenSetting.allowedAppIds) ? response.tokenSetting.allowedAppIds.map((item) => normalizeRequiredText(item)).filter(Boolean) : [], }, } satisfies ManagedChatShareRoom; } export async function saveChatShareRoomSettings( token: string, input: { sessionId?: string | null; accessPin?: string | null; accessPinPromptTtlMinutes?: number | null; chatTypeId?: string | null; chatTypeLabel?: string | null; title?: string | null; notifyOffline?: boolean | null; }, ) { const response = await requestChatApi<{ ok: boolean; hasAccessPin: boolean; accessPinPromptTtlMinutes?: number | null; conversation?: { sessionId?: string | null; title?: string | null; requestBadgeLabel?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean | null; } | null; }>( `/shares/${encodeURIComponent(token)}/room-settings`, { method: 'POST', body: JSON.stringify({ sessionId: input.sessionId, accessPin: input.accessPin, accessPinPromptTtlMinutes: input.accessPinPromptTtlMinutes, chatTypeId: input.chatTypeId, chatTypeLabel: input.chatTypeLabel, title: input.title, notifyOffline: input.notifyOffline, }), }, { allowUnauthenticated: true, }, ); return { hasAccessPin: response.hasAccessPin === true, accessPinPromptTtlMinutes: Number.isFinite(response.accessPinPromptTtlMinutes) && Number(response.accessPinPromptTtlMinutes) >= 0 ? Math.max(0, Number(response.accessPinPromptTtlMinutes)) : 0, conversation: response.conversation ? { sessionId: normalizeOptionalText(response.conversation.sessionId), title: normalizeOptionalText(response.conversation.title), requestBadgeLabel: normalizeOptionalText(response.conversation.requestBadgeLabel), chatTypeId: normalizeOptionalText(response.conversation.chatTypeId), lastChatTypeId: normalizeOptionalText(response.conversation.lastChatTypeId), contextLabel: normalizeOptionalText(response.conversation.contextLabel), contextDescription: normalizeOptionalText(response.conversation.contextDescription), notifyOffline: response.conversation.notifyOffline === true, } : null, }; } export async function fetchChatShareSnapshot( token: string, options?: { sharePin?: string | null; sessionId?: string | null; signal?: AbortSignal; view?: 'full' | 'initial'; }, ) { const query = new URLSearchParams(); if (options?.sessionId?.trim()) { query.set('sessionId', options.sessionId.trim()); } if (options?.view === 'initial') { query.set('view', 'initial'); } const response = await requestChatApi<{ ok: boolean; detailLevel?: ChatShareSnapshot['detailLevel']; share: ChatShareSnapshot['share']; conversation: ChatShareSnapshot['conversation']; rootRequestId: string; targetRequest: ChatConversationRequest; requests: ChatConversationRequest[]; messages: ChatMessage[]; activityLogs: ChatConversationActivityLog[]; rooms?: ChatShareRoomSummary[]; activeSessionId?: string | null; roomRequestCounts?: ChatShareSnapshot['roomRequestCounts']; oldestLoadedMessageId?: number | null; hasOlderMessages?: boolean; promptTarget?: ChatShareSnapshot['promptTarget']; refreshedAt: string; }>( `/shares/${encodeURIComponent(token)}${query.size > 0 ? `?${query.toString()}` : ''}`, undefined, { allowUnauthenticated: true, signal: options?.signal, sharePin: options?.sharePin, timeoutMs: 20000, }, ); return { detailLevel: response.detailLevel === 'initial' ? 'initial' : 'full', share: { ...response.share, createdAt: normalizeOptionalText(response.share?.createdAt), expiresAt: normalizeOptionalText(response.share?.expiresAt), tokenSetting: response.share?.tokenSetting ? { id: normalizeRequiredText(response.share.tokenSetting.id), name: normalizeRequiredText(response.share.tokenSetting.name), defaultExpiresInMinutes: Number.isFinite(response.share.tokenSetting.defaultExpiresInMinutes) ? Number(response.share.tokenSetting.defaultExpiresInMinutes) : 0, maxTokensPer30Days: Number.isFinite(response.share.tokenSetting.maxTokensPer30Days) ? Number(response.share.tokenSetting.maxTokensPer30Days) : 0, maxTokensPer7Days: Number.isFinite(response.share.tokenSetting.maxTokensPer7Days) ? Number(response.share.tokenSetting.maxTokensPer7Days) : 0, maxTokensPer5Hours: Number.isFinite(response.share.tokenSetting.maxTokensPer5Hours) ? Number(response.share.tokenSetting.maxTokensPer5Hours) : 0, oneTimeTokenLimit: Number.isFinite(response.share.tokenSetting.oneTimeTokenLimit) ? Number(response.share.tokenSetting.oneTimeTokenLimit) : 0, allowedAppIds: Array.isArray(response.share.tokenSetting.allowedAppIds) ? response.share.tokenSetting.allowedAppIds.map((item) => normalizeRequiredText(item)).filter(Boolean) : [], } : null, managedResourceTokenId: normalizeOptionalText(response.share?.managedResourceTokenId), hasAccessPin: response.share?.hasAccessPin === true, accessPinPromptTtlMinutes: Number.isFinite(response.share?.accessPinPromptTtlMinutes) && Number(response.share?.accessPinPromptTtlMinutes) >= 0 ? Math.max(0, Number(response.share?.accessPinPromptTtlMinutes)) : 0, accessPinSessionExpiresAt: normalizeOptionalText(response.share?.accessPinSessionExpiresAt), permissions: Array.isArray(response.share?.permissions) ? response.share.permissions.filter( (item): item is 'view' | 'download' | 'comment' | 'upload' | 'manage' => item === 'view' || item === 'download' || item === 'comment' || item === 'upload' || item === 'manage', ) : [], }, conversation: { ...response.conversation, chatTypeId: normalizeOptionalText(response.conversation?.chatTypeId), lastChatTypeId: normalizeOptionalText(response.conversation?.lastChatTypeId), contextLabel: normalizeOptionalText(response.conversation?.contextLabel), contextDescription: normalizeOptionalText(response.conversation?.contextDescription), notifyOffline: response.conversation?.notifyOffline === true, }, rootRequestId: response.rootRequestId, targetRequest: normalizeChatConversationRequest(response.targetRequest), requests: Array.isArray(response.requests) ? response.requests.map((item) => normalizeChatConversationRequest(item)) : [], messages: Array.isArray(response.messages) ? response.messages.map((message, index) => normalizeChatMessage(message, index)) : [], activityLogs: Array.isArray(response.activityLogs) ? response.activityLogs : [], rooms: Array.isArray(response.rooms) ? response.rooms.map((item) => ({ sessionId: normalizeRequiredText(item.sessionId), requestId: normalizeRequiredText(item.requestId), isDefault: item.isDefault === true, sortOrder: Number.isFinite(item.sortOrder) ? Number(item.sortOrder) : 0, title: normalizeRequiredText(item.title) || '공유 채팅방', requestBadgeLabel: normalizeOptionalText(item.requestBadgeLabel), chatTypeId: normalizeOptionalText(item.chatTypeId), lastChatTypeId: normalizeOptionalText(item.lastChatTypeId), contextLabel: normalizeOptionalText(item.contextLabel), contextDescription: normalizeOptionalText(item.contextDescription), notifyOffline: item.notifyOffline === true, linkContext: item.linkContext?.kind === 'linked-session' ? { kind: 'linked-session', sourceSessionId: normalizeRequiredText(item.linkContext.sourceSessionId), sourceRequestId: normalizeRequiredText(item.linkContext.sourceRequestId), sourceTitle: normalizeOptionalText(item.linkContext.sourceTitle), sourceRequestPreview: normalizeOptionalText(item.linkContext.sourceRequestPreview), sourceChatTypeLabel: normalizeOptionalText(item.linkContext.sourceChatTypeLabel), linkedAt: normalizeOptionalText(item.linkContext.linkedAt), } : null, createdAt: normalizeOptionalText(item.createdAt), updatedAt: normalizeOptionalText(item.updatedAt), })) : [], activeSessionId: normalizeOptionalText(response.activeSessionId), roomRequestCounts: response.roomRequestCounts ? { processingCount: Number.isFinite(response.roomRequestCounts.processingCount) ? response.roomRequestCounts.processingCount : 0, unansweredCount: Number.isFinite(response.roomRequestCounts.unansweredCount) ? response.roomRequestCounts.unansweredCount : 0, } : undefined, oldestLoadedMessageId: Number.isFinite(response.oldestLoadedMessageId) && Number(response.oldestLoadedMessageId) > 0 ? Number(response.oldestLoadedMessageId) : null, hasOlderMessages: response.hasOlderMessages === true, promptTarget: response.promptTarget ?? null, refreshedAt: response.refreshedAt, } satisfies ChatShareSnapshot; } export async function submitChatShareMessage( token: string, text: string, options?: { sessionId?: string | null; mode?: 'queue' | 'direct'; parentRequestId?: string | null; codexModel?: string | null; }, ) { return requestChatApi<{ ok: boolean; queuedRequestId: string }>( `/shares/${encodeURIComponent(token)}/messages`, { method: 'POST', body: JSON.stringify({ text, sessionId: options?.sessionId?.trim() || undefined, mode: options?.mode === 'direct' ? 'direct' : 'queue', parentRequestId: options?.parentRequestId?.trim() || undefined, codexModel: options?.codexModel?.trim() || undefined, }), }, { allowUnauthenticated: true, }, ); } export async function submitChatShareOriginReply( token: string, payload: { sessionId?: string | null; sourceSessionId: string; sourceRequestId: string; text: string; mode?: 'queue' | 'direct'; }, ) { return requestChatApi<{ ok: boolean; queuedRequestId: string }>( `/shares/${encodeURIComponent(token)}/origin-reply`, { method: 'POST', body: JSON.stringify({ sessionId: payload.sessionId?.trim() || undefined, sourceSessionId: payload.sourceSessionId.trim(), sourceRequestId: payload.sourceRequestId.trim(), text: payload.text, mode: payload.mode === 'direct' ? 'direct' : 'queue', }), }, { allowUnauthenticated: true, }, ); } export async function submitChatSharePrompt( token: string, payload: { parentRequestId: string; promptIndex: number; promptTitle: string; promptSignature: string; sourceMessageId: number; selectedValues: string[]; freeText?: string | null; stepSelections?: Array<{ stepKey: string; stepTitle: string; selectedValues: string[]; freeText: string; skipped?: boolean; }>; summaryText?: string | null; attachments?: ChatComposerAttachment[]; followupText: string; mode?: 'queue' | 'direct'; contextRef?: ChatPromptContextRef | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest; message: ChatMessage; queuedRequestId: string }>( `/shares/${encodeURIComponent(token)}/prompt-submit`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); return { queuedRequestId: response.queuedRequestId, item: normalizeChatConversationRequest(response.item), message: { ...normalizeChatMessage(response.message), }, }; } export async function completeChatShareManualBadge( token: string, payload: { parentRequestId: string; sessionId?: string | null; type: 'prompt' | 'verification'; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>( `/shares/${encodeURIComponent(token)}/manual-completion`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); return normalizeChatConversationRequest(response.item); } export async function cancelChatShareRequest( token: string, payload: { parentRequestId: string; sessionId?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>( `/shares/${encodeURIComponent(token)}/request-cancel`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); return normalizeChatConversationRequest(response.item); } export async function retryChatShareRequest( token: string, payload: { parentRequestId: string; sessionId?: string | null; }, ) { return requestChatApi<{ ok: boolean; queuedRequestId: string }>( `/shares/${encodeURIComponent(token)}/request-retry`, { method: 'POST', body: JSON.stringify(payload), }, { allowUnauthenticated: true, }, ); } 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; onRequestEvent?: (request: ChatConversationRequest, sessionId: string) => 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]; const leftParts = JSON.stringify(message.parts ?? []); const rightParts = JSON.stringify(other?.parts ?? []); return ( other && message.id === other.id && message.author === other.author && message.text === other.text && message.timestamp === other.timestamp && leftParts === rightParts ); }); } export function upsertChatMessage(previous: ChatMessage[], incoming: ChatMessage) { const existingIndex = previous.findIndex( (message) => message.id === incoming.id || Boolean( incoming.clientRequestId && message.clientRequestId && (incoming.author === 'user' || incoming.author === 'codex') && (message.author === 'user' || message.author === 'codex') && incoming.author === message.author && 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 : shouldPreserveExistingCodexMessageText(existingMessage, incoming) ? existingMessage.text : incoming.text; nextMessages[existingIndex] = { ...existingMessage, ...incoming, text: nextText, parts: incoming.parts ?? existingMessage.parts ?? [], 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); } if (isMissingRequestMessage(left) && isMissingRequestMessage(right)) { return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId); } if (isExecutionFailureMessage(left) && isExecutionFailureMessage(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 (isMissingRequestMessage(message) && message.clientRequestId) { return `missing-request:${message.clientRequestId}`; } if (isExecutionFailureMessage(message) && message.clientRequestId) { return `execution-failure:${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 getChatMessageRequestId(message: ChatMessage) { return message.clientRequestId?.trim() || ''; } function getChatMessageOrderRank(message: ChatMessage) { if (message.author === 'user') { return 0; } if (isMissingRequestMessage(message)) { return 1; } if (isExecutionFailureMessage(message)) { return 2; } if (isActivityLogMessage(message)) { return 3; } if (message.author === 'codex') { // Keep streamed Codex replies after their request activity rows so the // message block does not jump around as timestamps refresh on each update. return 4; } return 5; } function sortConversationMessages(messages: ChatMessage[]) { if (messages.length <= 1) { return messages; } const messageIndexMap = new Map(messages.map((message, index) => [message, index])); const requestOrder = new Map< string, { time: number; firstIndex: number; } >(); messages.forEach((message, index) => { const requestId = getChatMessageRequestId(message); if (!requestId) { return; } const time = getComparableChatMessageTime(message); const existing = requestOrder.get(requestId); if (!existing) { requestOrder.set(requestId, { time, firstIndex: index, }); return; } requestOrder.set(requestId, { time: existing.time > 0 && time > 0 ? Math.min(existing.time, time) : existing.time > 0 ? existing.time : time, firstIndex: Math.min(existing.firstIndex, index), }); }); return [...messages].sort((left, right) => { const leftRequestId = getChatMessageRequestId(left); const rightRequestId = getChatMessageRequestId(right); if (leftRequestId && rightRequestId && leftRequestId === rightRequestId) { const rankDiff = getChatMessageOrderRank(left) - getChatMessageOrderRank(right); if (rankDiff !== 0) { return rankDiff; } } const leftOrder = leftRequestId ? requestOrder.get(leftRequestId) : null; const rightOrder = rightRequestId ? requestOrder.get(rightRequestId) : null; const leftTime = leftOrder?.time ?? getComparableChatMessageTime(left); const rightTime = rightOrder?.time ?? getComparableChatMessageTime(right); if (leftTime !== rightTime) { return leftTime - rightTime; } const leftIndex = leftOrder?.firstIndex ?? messageIndexMap.get(left) ?? 0; const rightIndex = rightOrder?.firstIndex ?? messageIndexMap.get(right) ?? 0; if (leftIndex !== rightIndex) { return leftIndex - rightIndex; } if (leftRequestId && rightRequestId && leftRequestId !== rightRequestId) { const requestDiff = leftRequestId.localeCompare(rightRequestId, 'ko-KR'); if (requestDiff !== 0) { return requestDiff; } } const messageTimeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right); if (messageTimeDiff !== 0) { return messageTimeDiff; } return left.id - right.id; }); } 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); const failureReason = extractActivityLogFailureReason(activityLog?.lines); const shouldReplaceEmptyFailureResponse = request.status === 'failed' && isEmptyCodexExecutionResponse(responseText) && Boolean(failureReason); if (userText) { nextMessages.push({ id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'), author: 'user', text: userText, timestamp: request.createdAt || request.updatedAt || '', clientRequestId: requestId, }); } else if (responseText || activityLog?.lines.length) { nextMessages.push({ id: createRecoveredMessageId(requestId, 'missing-request'), author: 'system', text: `${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n이 요청은 저장된 원문이 없어 실제 요청 문장을 표시할 수 없습니다.`, timestamp: request.createdAt || request.updatedAt || '', clientRequestId: requestId, }); } if (responseText && !shouldReplaceEmptyFailureResponse) { nextMessages.push({ id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'), author: 'codex', text: responseText, timestamp: request.answeredAt || request.updatedAt || request.createdAt || '', clientRequestId: requestId, }); } if (shouldReplaceEmptyFailureResponse) { nextMessages.push({ id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'execution-failure'), author: 'system', text: buildExecutionFailureMessage(failureReason), 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 sortConversationMessages(nextMessages); } 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, text: shouldPreserveExistingCodexMessageText(existingMessage, serverMessage) ? existingMessage.text : serverMessage.text, deliveryStatus: null, retryCount: 0, }; }); const incomingUserRequestIds = new Set( incoming .filter((message) => message.author === 'user') .map((message) => getChatMessageRequestId(message)) .filter(Boolean), ); const unmatchedLocalMessages = Array.from(previousBuckets.values()) .flat() .filter((message) => { if (!isMissingRequestMessage(message)) { return true; } const requestId = getChatMessageRequestId(message); if (!requestId) { return true; } return !incomingUserRequestIds.has(requestId); }); const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]); return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages; } export async function handleChatServerEvent({ eventData, currentPageUrl, expectedSessionId, setMessages, onMessageEvent, onJobEvent, onRuntimeEvent, onRuntimeDetailEvent, onActivityEvent, onRequestEvent, 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') { const normalizedMessages = Array.isArray(payload.payload.messages) ? payload.payload.messages.map((message, index) => normalizeChatMessage(message, index)) : []; setMessages((previous) => mergeRecoveredChatMessages(previous, normalizedMessages)); return; } if (payload.type === 'chat:message') { const normalizedMessage = normalizeChatMessage(payload.payload); onMessageEvent?.(normalizedMessage, payload.sessionId); setMessages((previous) => upsertChatMessage(previous, normalizedMessage)); return; } if (payload.type === 'chat:message:update') { const normalizedMessage = normalizeChatMessage(payload.payload); setMessages((previous) => upsertChatMessage(previous, normalizedMessage)); 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:request:update') { onRequestEvent?.(normalizeChatConversationRequest(payload.payload), payload.sessionId); return; } if (payload.type === 'notification:messages-updated') { notifyNotificationMessagesUpdated(); 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('채팅 응답을 해석하지 못했습니다. 연결을 다시 확인합니다.'), ]); } }