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

1545 lines
45 KiB
TypeScript

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