chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -24,6 +24,8 @@ 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_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const KST_TIME_ZONE = 'Asia/Seoul';
const chatSessionLastTypeMemory = new Map<string, string>();
@@ -46,18 +48,23 @@ function toConversationSortTime(value: string | null | undefined) {
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),
);
}
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),
);
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime - leftTime;
@@ -289,18 +296,29 @@ function createLocalMessageId() {
return Date.now() * 1_000 + localMessageSequence;
}
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
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 === 'activity') {
if (variant === 'missing-request') {
return -(baseId + 2);
}
return -(baseId + 1);
if (variant === 'activity') {
return -(baseId + 1);
}
if (variant === 'execution-failure') {
return -(baseId + 4);
}
return -(baseId + 5);
}
function hashRequestId(value: string) {
@@ -355,6 +373,170 @@ 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)
@@ -934,7 +1116,9 @@ export async function fetchChatConversations() {
}
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>(
'/conversations?limit=200',
)
.then((response) => {
return sortChatConversationSummaries(
response.items.map((item) => ({
@@ -971,17 +1155,24 @@ export async function fetchChatConversationDetail(
const response = await requestChatApi<ChatConversationDetailResponse>(
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
);
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
const normalizedRequests = enrichFailedRequestsWithActivityLogs(
response.requests.map((item) => normalizeChatConversationRequest(item)),
response.activityLogs,
);
const visibleRequestIds = new Set(
response.messages
.map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean),
);
const hydratedMessages = hydrateActivityLogMessages(
response.messages,
replaceGenericFailureMessages(response.messages, normalizedRequests, response.activityLogs),
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
(message) =>
message.author !== 'system' ||
isActivityLogMessage(message) ||
isMissingRequestMessage(message) ||
isExecutionFailureMessage(message),
);
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
@@ -990,6 +1181,11 @@ export async function fetchChatConversationDetail(
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
item: {
...response.item,
lastMessagePreview: resolveConversationFailurePreview(
response.item.lastMessagePreview,
normalizedRequests,
response.activityLogs,
),
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
response.item.sessionId,
response.item.notifyOffline,
@@ -1123,6 +1319,7 @@ export async function createChatConversationRoom(args: {
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -1136,6 +1333,7 @@ export async function createChatConversationRoom(args: {
title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
generalSectionName: args.generalSectionName ?? null,
contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null,
notifyOffline,
@@ -1173,6 +1371,7 @@ export async function updateChatConversationRoom(
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean;
@@ -1307,6 +1506,14 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
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 &&
@@ -1321,6 +1528,14 @@ function buildComparableChatMessageKey(message: ChatMessage) {
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}`;
}
@@ -1337,6 +1552,123 @@ function getComparableChatMessageTime(message: ChatMessage) {
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') {
return 3;
}
return 4;
}
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[],
@@ -1354,6 +1686,9 @@ function buildRecoveredMessagesFromConversationDetail(
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({
@@ -1363,9 +1698,17 @@ function buildRecoveredMessagesFromConversationDetail(
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) {
if (responseText && !shouldReplaceEmptyFailureResponse) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
author: 'codex',
@@ -1375,6 +1718,16 @@ function buildRecoveredMessagesFromConversationDetail(
});
}
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'),
@@ -1386,15 +1739,7 @@ function buildRecoveredMessagesFromConversationDetail(
}
});
return nextMessages.sort((left, right) => {
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (timeDiff !== 0) {
return timeDiff;
}
return left.id - right.id;
});
return sortConversationMessages(nextMessages);
}
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
@@ -1459,7 +1804,7 @@ export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: Ch
});
const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
const nextMessages = [...mergedServerMessages, ...unmatchedLocalMessages];
const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
}