3725 lines
117 KiB
TypeScript
3725 lines
117 KiB
TypeScript
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<string, string>();
|
|
const chatLastEventIdMemory = new Map<string, number>();
|
|
const chatOfflineNotificationMemory = new Map<string, boolean>();
|
|
const chatShareAccessPinMemory = new Map<string, { pin: string; expiresAtMs: number | null }>();
|
|
let chatClientSessionIdMemory = '';
|
|
let localMessageSequence = 0;
|
|
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | 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<string, { pin: string; expiresAtMs: number | null }>;
|
|
}
|
|
|
|
try {
|
|
const rawValue = window.localStorage.getItem(CHAT_SHARE_ACCESS_PIN_STORAGE_KEY);
|
|
if (!rawValue) {
|
|
return {} as Record<string, { pin: string; expiresAtMs: number | null }>;
|
|
}
|
|
|
|
const parsed = JSON.parse(rawValue) as Record<string, { pin?: unknown; expiresAtMs?: unknown }>;
|
|
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<string, { pin: string; expiresAtMs: number | null }>;
|
|
}
|
|
}
|
|
|
|
function writeStoredChatShareAccessPins(entries: Record<string, { pin: string; expiresAtMs: number | null }>) {
|
|
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<Extract<ChatMessagePart, { type: 'prompt' }>['options'][number]['preview']>
|
|
>;
|
|
type PromptOption = Extract<ChatMessagePart, { type: 'prompt' }>['options'][number];
|
|
type PromptStep = NonNullable<Extract<ChatMessagePart, { type: 'prompt' }>['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<ChatMessagePart, { type: 'link_card' }>;
|
|
}
|
|
|
|
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<ChatConversationSummary[]>((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<ChatConversationRequest, 'status' | 'statusMessage'> | null | undefined,
|
|
nextItem: Pick<ChatConversationRequest, 'status' | 'statusMessage'>,
|
|
) {
|
|
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<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 = 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<PreviewShareResult> {
|
|
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<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,
|
|
options?: {
|
|
allowUnauthenticated?: boolean;
|
|
signal?: AbortSignal;
|
|
timeoutMs?: number;
|
|
sharePin?: string | null;
|
|
},
|
|
): Promise<T> {
|
|
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<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',
|
|
};
|
|
|
|
const FALLBACK_UPLOAD_EXTENSION_BY_MIME: Record<string, string> = {
|
|
'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<ChatConversationDetailResponse>(
|
|
`/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<ChatConversationDetailResponse>(
|
|
`/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<ChatSourceChangeSnapshotListResponse>(`/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<string, unknown> | 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<string, unknown> | 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<ChatMessagePart, { type: 'prompt' }>;
|
|
} | 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<ChatShareSnapshot['share']['tokenSetting']>;
|
|
};
|
|
|
|
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<ChatShareSnapshot['share']['tokenSetting']>;
|
|
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<ChatShareSnapshot['share']['tokenSetting']>;
|
|
}>(
|
|
'/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<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;
|
|
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<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,
|
|
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('채팅 응답을 해석하지 못했습니다. 연결을 다시 확인합니다.'),
|
|
]);
|
|
}
|
|
}
|