chore: update live chat and work server changes

This commit is contained in:
2026-04-26 16:37:06 +09:00
parent 63e5d263a7
commit 20a6333ed2
38 changed files with 2078 additions and 2281 deletions

View File

@@ -24,20 +24,16 @@ 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 CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
const chatSessionLastTypeMemory = new Map<string, string>();
const chatLastEventIdMemory = new Map<string, number>();
const chatOfflineNotificationMemory = new Map<string, boolean>();
let chatClientSessionIdMemory = '';
let localMessageSequence = 0;
let cachedChatConversationList: ChatConversationSummary[] | null = null;
let cachedChatConversationListAt = 0;
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
export function invalidateChatConversationListCache() {
cachedChatConversationList = null;
cachedChatConversationListAt = 0;
chatConversationListRequestPromise = null;
}
@@ -817,6 +813,16 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
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 {
@@ -894,16 +900,35 @@ async function readFileAsBase64(file: File) {
});
}
export async function fetchChatConversations() {
const now = Date.now();
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',
};
if (
cachedChatConversationList &&
now - cachedChatConversationListAt < CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS
) {
return cachedChatConversationList;
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;
}
@@ -911,16 +936,12 @@ export async function fetchChatConversations() {
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
.then((response) => {
const items = sortChatConversationSummaries(
return sortChatConversationSummaries(
response.items.map((item) => ({
...item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
})),
);
cachedChatConversationList = items;
cachedChatConversationListAt = Date.now();
return items;
})
.finally(() => {
chatConversationListRequestPromise = null;
@@ -1026,23 +1047,75 @@ export async function rollbackChatRuntimeJob(requestId: string, sessionId?: stri
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) {
throw new Error('채팅 세션이 준비되지 않았습니다.');
const uploadError = new Error('채팅 세션이 준비되지 않았습니다.');
await reportUploadFailure('validate-session', uploadError);
throw uploadError;
}
const contentBase64 = await readFileAsBase64(file);
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
method: 'POST',
body: JSON.stringify({
sessionId: normalizedSessionId,
fileName: file.name,
mimeType: file.type,
contentBase64,
}),
});
if (file.size <= 0) {
const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
await reportUploadFailure('validate-file', uploadError);
throw uploadError;
}
return response.item;
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: {