feat: update codex live chat workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../tokenAccess';
|
||||
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import { reportClientError } from '../errorLogApi';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
@@ -17,28 +17,66 @@ import type {
|
||||
ChatViewContext,
|
||||
} from './types';
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 8000;
|
||||
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_MESSAGES_STORAGE_PREFIX = 'main-chat-panel:messages:';
|
||||
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 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;
|
||||
}
|
||||
|
||||
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: 3000,
|
||||
reconnectDelayMs: 1500,
|
||||
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
||||
sessionIdKey: CHAT_SESSION_ID_KEY,
|
||||
lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX,
|
||||
notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX,
|
||||
sessionMessagesStoragePrefix: CHAT_SESSION_MESSAGES_STORAGE_PREFIX,
|
||||
sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX,
|
||||
introMessage: CHAT_INTRO_MESSAGE,
|
||||
} as const;
|
||||
@@ -49,21 +87,6 @@ function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | nul
|
||||
return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`;
|
||||
}
|
||||
|
||||
function buildLastEventIdStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.lastEventIdStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function buildSessionMessagesStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.sessionMessagesStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function buildSessionLastChatTypeStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.sessionLastTypeStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function createBrowserSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
@@ -77,29 +100,10 @@ export function clearStoredChatClientConversationState() {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
key === CHAT_CONNECTION.sessionIdKey ||
|
||||
key.startsWith(CHAT_CONNECTION.lastEventIdStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.notifyOfflineStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.sessionMessagesStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.sessionLastTypeStoragePrefix)
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
window.localStorage.removeItem(key);
|
||||
});
|
||||
chatClientSessionIdMemory = '';
|
||||
chatSessionLastTypeMemory.clear();
|
||||
chatLastEventIdMemory.clear();
|
||||
chatOfflineNotificationMemory.clear();
|
||||
}
|
||||
|
||||
function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest {
|
||||
@@ -123,15 +127,12 @@ export function getChatClientSessionId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const existing = window.localStorage.getItem(CHAT_CONNECTION.sessionIdKey)?.trim();
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
if (chatClientSessionIdMemory) {
|
||||
return chatClientSessionIdMemory;
|
||||
}
|
||||
|
||||
const nextSessionId = createBrowserSessionId();
|
||||
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, nextSessionId);
|
||||
return nextSessionId;
|
||||
chatClientSessionIdMemory = createBrowserSessionId();
|
||||
return chatClientSessionIdMemory;
|
||||
}
|
||||
|
||||
export function setChatClientSessionId(sessionId: string) {
|
||||
@@ -145,7 +146,7 @@ export function setChatClientSessionId(sessionId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, normalizedSessionId);
|
||||
chatClientSessionIdMemory = normalizedSessionId;
|
||||
}
|
||||
|
||||
export function getLastReceivedChatEventId(sessionId: string) {
|
||||
@@ -159,9 +160,7 @@ export function getLastReceivedChatEventId(sessionId: string) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildLastEventIdStorageKey(normalizedSessionId));
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
return chatLastEventIdMemory.get(normalizedSessionId) ?? 0;
|
||||
}
|
||||
|
||||
export function persistLastReceivedChatEventId(sessionId: string, eventId: number) {
|
||||
@@ -181,7 +180,7 @@ export function persistLastReceivedChatEventId(sessionId: string, eventId: numbe
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildLastEventIdStorageKey(normalizedSessionId), String(eventId));
|
||||
chatLastEventIdMemory.set(normalizedSessionId, eventId);
|
||||
}
|
||||
|
||||
export function resetLastReceivedChatEventId(sessionId: string) {
|
||||
@@ -195,7 +194,7 @@ export function resetLastReceivedChatEventId(sessionId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildLastEventIdStorageKey(normalizedSessionId));
|
||||
chatLastEventIdMemory.delete(normalizedSessionId);
|
||||
}
|
||||
|
||||
export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
|
||||
@@ -203,13 +202,13 @@ export function getStoredChatOfflineNotificationSetting(sessionId: string, clien
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
const key = buildNotifyOfflineStorageKey(sessionId, clientId);
|
||||
|
||||
if (raw === null) {
|
||||
if (!chatOfflineNotificationMemory.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw === 'true';
|
||||
return chatOfflineNotificationMemory.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) {
|
||||
@@ -217,7 +216,7 @@ export function setStoredChatOfflineNotificationSetting(sessionId: string, enabl
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildNotifyOfflineStorageKey(sessionId, clientId), enabled ? 'true' : 'false');
|
||||
chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled);
|
||||
}
|
||||
|
||||
export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
|
||||
@@ -225,7 +224,7 @@ export function clearStoredChatOfflineNotificationSetting(sessionId: string, cli
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
}
|
||||
|
||||
function resolveSyncedChatOfflineNotificationSetting(
|
||||
@@ -247,106 +246,31 @@ function resolveSyncedChatOfflineNotificationSetting(
|
||||
return serverValue;
|
||||
}
|
||||
|
||||
export function loadStoredChatMessages(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(buildSessionMessagesStorageKey(normalizedSessionId));
|
||||
|
||||
if (!raw) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as ChatMessage[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((message) =>
|
||||
Boolean(message) &&
|
||||
(message.author === 'codex' || message.author === 'system' || message.author === 'user') &&
|
||||
typeof message.text === 'string' &&
|
||||
typeof message.timestamp === 'string' &&
|
||||
typeof message.id === 'number',
|
||||
)
|
||||
.filter((message) => message.author !== 'system' || isActivityLogMessage(message));
|
||||
} catch {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredChatSessionLastTypeId(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildSessionLastChatTypeStorageKey(normalizedSessionId))?.trim() ?? '';
|
||||
const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? '';
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
const normalizedChatTypeId = chatTypeId.trim();
|
||||
|
||||
if (!normalizedSessionId || !normalizedChatTypeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
buildSessionLastChatTypeStorageKey(normalizedSessionId),
|
||||
normalizedChatTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
export function persistStoredChatMessages(sessionId: string, messages: ChatMessage[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
buildSessionMessagesStorageKey(normalizedSessionId),
|
||||
JSON.stringify(messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message))),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearStoredChatMessages(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
if (!normalizedChatTypeId) {
|
||||
chatSessionLastTypeMemory.delete(normalizedSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildSessionMessagesStorageKey(normalizedSessionId));
|
||||
chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId);
|
||||
}
|
||||
|
||||
export function formatTime(date: Date) {
|
||||
@@ -369,6 +293,20 @@ function createLocalMessageId() {
|
||||
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;
|
||||
|
||||
@@ -606,6 +544,7 @@ export function buildOfflineReply(context: ChatViewContext, input: string) {
|
||||
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 '';
|
||||
@@ -626,6 +565,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
|
||||
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));
|
||||
}
|
||||
@@ -641,6 +583,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
|
||||
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));
|
||||
}
|
||||
@@ -736,6 +681,106 @@ export async function copyText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -751,6 +796,11 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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);
|
||||
}
|
||||
@@ -775,17 +825,43 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
throw new Error('채팅 서버 응답이 지연됩니다.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw new Error('채팅 서버 연결에 실패했습니다.');
|
||||
}
|
||||
|
||||
window.clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || '채팅 API 요청에 실패했습니다.');
|
||||
|
||||
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 요청에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
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) {
|
||||
@@ -827,10 +903,12 @@ export async function fetchChatConversations() {
|
||||
const clientId = getOrCreateClientId();
|
||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
||||
.then((response) => {
|
||||
const items = response.items.map((item) => ({
|
||||
...item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||
}));
|
||||
const items = sortChatConversationSummaries(
|
||||
response.items.map((item) => ({
|
||||
...item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||
})),
|
||||
);
|
||||
|
||||
cachedChatConversationList = items;
|
||||
cachedChatConversationListAt = Date.now();
|
||||
@@ -864,19 +942,23 @@ 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 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: hydrateActivityLogMessages(
|
||||
response.messages,
|
||||
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
|
||||
).filter(
|
||||
(message) => message.author !== 'system' || isActivityLogMessage(message),
|
||||
),
|
||||
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
|
||||
item: {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
|
||||
@@ -885,7 +967,7 @@ export async function fetchChatConversationDetail(
|
||||
clientId,
|
||||
),
|
||||
},
|
||||
requests: response.requests.map((item) => normalizeChatConversationRequest(item)),
|
||||
requests: normalizedRequests,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -945,6 +1027,7 @@ export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||
export async function createChatConversationRoom(args: {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -956,6 +1039,7 @@ export async function createChatConversationRoom(args: {
|
||||
body: JSON.stringify({
|
||||
sessionId: args.sessionId,
|
||||
title: args.title ?? '새 대화',
|
||||
chatTypeId: args.chatTypeId ?? null,
|
||||
contextLabel: args.contextLabel ?? null,
|
||||
contextDescription: args.contextDescription ?? null,
|
||||
notifyOffline,
|
||||
@@ -963,6 +1047,8 @@ export async function createChatConversationRoom(args: {
|
||||
}),
|
||||
});
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
@@ -980,6 +1066,8 @@ export async function renameChatConversationRoom(sessionId: string, title: strin
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
@@ -987,6 +1075,9 @@ export async function updateChatConversationRoom(
|
||||
sessionId: string,
|
||||
payload: {
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -999,6 +1090,8 @@ export async function updateChatConversationRoom(
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
@@ -1025,6 +1118,8 @@ export async function deleteChatConversationRoom(sessionId: string) {
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1116,8 +1211,8 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
left.author === 'user' &&
|
||||
right.author === 'user' &&
|
||||
(left.author === 'user' || left.author === 'codex') &&
|
||||
left.author === right.author &&
|
||||
left.clientRequestId &&
|
||||
right.clientRequestId &&
|
||||
left.clientRequestId === right.clientRequestId,
|
||||
@@ -1133,9 +1228,78 @@ function buildComparableChatMessageKey(message: ChatMessage) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user