diff --git a/.dockerignore b/.dockerignore index a0a1ecf..7cb7ada 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git .auto_codex .docker +etc/servers/work-server/.docker .idea .vscode node_modules diff --git a/etc/servers/work-server/scripts/container-supervisor.sh b/etc/servers/work-server/scripts/container-supervisor.sh index 9506cb2..c4cddfd 100755 --- a/etc/servers/work-server/scripts/container-supervisor.sh +++ b/etc/servers/work-server/scripts/container-supervisor.sh @@ -45,7 +45,7 @@ prepare_runtime() { start_child() { log "starting server process" - npm run start & + node dist/server.js & CHILD_PID=$! } diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index 609a8d4..f0e76dc 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -1,6 +1,12 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js'; +import { + getAppConfig, + getChatTypesConfig, + normalizeAppConfigSnapshot, + upsertAppConfig, + upsertChatTypesConfig, +} from '../services/app-config-service.js'; import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js'; export async function registerAppConfigRoutes(app: FastifyInstance) { @@ -9,7 +15,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { return { ok: true, - config: config ?? {}, + config: normalizeAppConfigSnapshot(config), }; }); @@ -115,7 +121,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { return { ok: true, - config: savedConfig, + config: normalizeAppConfigSnapshot(savedConfig), }; } catch (error) { return reply.code(409).send({ diff --git a/etc/servers/work-server/src/services/app-config-service.test.ts b/etc/servers/work-server/src/services/app-config-service.test.ts new file mode 100644 index 0000000..dc6d20e --- /dev/null +++ b/etc/servers/work-server/src/services/app-config-service.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mergeDefaultChatTypes } from './app-config-service.js'; + +test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => { + const merged = mergeDefaultChatTypes([ + { + id: 'general-request', + name: '일반 요청', + description: '사용자가 수정한 일반 요청 문맥', + permissions: ['guest', 'token-user'], + enabled: true, + updatedAt: '2026-04-24T09:00:00.000Z', + }, + ]); + + const generalRequest = merged.find((item) => item.id === 'general-request'); + + assert.ok(generalRequest); + assert.equal(generalRequest.description, '사용자가 수정한 일반 요청 문맥'); + assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']); +}); + +test('mergeDefaultChatTypes still appends missing built-in chat types', () => { + const merged = mergeDefaultChatTypes([]); + + assert.ok(merged.some((item) => item.id === 'general-request')); + assert.ok(merged.some((item) => item.id === 'api-request-template')); + assert.ok(merged.some((item) => item.id === 'general-inquiry')); +}); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index d89bd31..9254ebe 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -2,6 +2,11 @@ import { db } from '../db/client.js'; export const APP_CONFIG_TABLE = 'app_configs'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; +const DEFAULT_CHAT_APP_CONFIG = { + maxContextMessages: 12, + maxContextChars: 3200, + codexLiveMaxExecutionSeconds: 600, +} as const; type ChatPermissionRole = 'guest' | 'token-user'; @@ -19,7 +24,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ id: 'general-request', name: '일반 요청', description: - '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.', + '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', permissions: ['token-user'], enabled: true, updatedAt: '2026-04-23T00:00:00.000Z', @@ -98,6 +103,14 @@ function normalizeConfigRecord(value: unknown) { return value as Record; } +function normalizeIntegerInRange(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + + return Math.min(max, Math.max(min, Math.round(value))); +} + function normalizeText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } @@ -183,24 +196,14 @@ function sanitizeChatTypes(items: unknown[]) { return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); } -function mergeDefaultChatTypes(items: unknown[]) { +export function mergeDefaultChatTypes(items: unknown[]) { const savedItems = sanitizeChatTypes(items); const byId = new Map(savedItems.map((item) => [item.id, item] as const)); for (const defaultItem of DEFAULT_CHAT_TYPES) { - const existingItem = byId.get(defaultItem.id); - - if (!existingItem) { + if (!byId.has(defaultItem.id)) { byId.set(defaultItem.id, defaultItem); - continue; } - - byId.set(defaultItem.id, { - ...existingItem, - name: defaultItem.name, - description: defaultItem.description, - permissions: defaultItem.permissions, - }); } return sanitizeChatTypes(Array.from(byId.values())); @@ -230,6 +233,7 @@ export type AppConfigSnapshot = { chat?: { maxContextMessages?: number; maxContextChars?: number; + codexLiveMaxExecutionSeconds?: number; }; automation?: { autoRefreshEnabled?: boolean; @@ -260,26 +264,45 @@ export type AppConfigSnapshot = { }; }; +export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot { + const normalized = normalizeConfigRecord(value); + const chat = normalizeConfigRecord(normalized.chat); + const worklogAutomation = normalizeConfigRecord(normalized.worklogAutomation); + + return { + ...(normalized as AppConfigSnapshot), + chat: { + maxContextMessages: normalizeIntegerInRange( + chat.maxContextMessages, + DEFAULT_CHAT_APP_CONFIG.maxContextMessages, + 1, + 50, + ), + maxContextChars: normalizeIntegerInRange( + chat.maxContextChars, + DEFAULT_CHAT_APP_CONFIG.maxContextChars, + 500, + 20_000, + ), + codexLiveMaxExecutionSeconds: normalizeIntegerInRange( + chat.codexLiveMaxExecutionSeconds, + DEFAULT_CHAT_APP_CONFIG.codexLiveMaxExecutionSeconds, + 60, + 7200, + ), + }, + worklogAutomation: + Object.keys(worklogAutomation).length > 0 + ? { + ...(normalized.worklogAutomation as AppConfigSnapshot['worklogAutomation']), + repeatRequestEnabled: false, + } + : undefined, + }; +} + export async function getAppConfigSnapshot(): Promise { - const raw = await getAppConfig(); - - if (!raw || typeof raw !== 'object') { - return {}; - } - - const snapshot = raw as AppConfigSnapshot; - - if (snapshot.worklogAutomation) { - return { - ...snapshot, - worklogAutomation: { - ...snapshot.worklogAutomation, - repeatRequestEnabled: false, - }, - }; - } - - return snapshot; + return normalizeAppConfigSnapshot(await getAppConfig()); } export async function upsertAppConfig(config: Record) { diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index 315e56a..0896805 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -4,6 +4,7 @@ import { buildChatConversationRequestPatchFromMessage, isVisibleConversationMessage, mergeChatConversationRequestStatus, + resolveNextConversationChatTypeId, shouldClearConversationJobState, selectChatConversationResponseCandidate, } from './chat-room-service.js'; @@ -15,6 +16,17 @@ test('mergeChatConversationRequestStatus keeps terminal states from being downgr assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed'); }); +test('resolveNextConversationChatTypeId prefers the requested chat type over the stored one', () => { + assert.equal(resolveNextConversationChatTypeId('general-request', 'api-request-template'), 'api-request-template'); + assert.equal(resolveNextConversationChatTypeId('general-request', ' api-request-template '), 'api-request-template'); +}); + +test('resolveNextConversationChatTypeId falls back to the stored chat type when the request is empty', () => { + assert.equal(resolveNextConversationChatTypeId('general-request', null), 'general-request'); + assert.equal(resolveNextConversationChatTypeId('general-request', ' '), 'general-request'); + assert.equal(resolveNextConversationChatTypeId(null, null), null); +}); + test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => { assert.equal( buildChatConversationRequestPatchFromMessage({ diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index 499e428..ec808a8 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -1072,7 +1072,7 @@ export async function updateChatConversationContext( const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null; const requestedChatTypeId = payload.chatTypeId?.trim() || null; - const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null; + const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId); const requestedContextLabel = payload.contextLabel?.trim() || null; const requestedContextDescription = payload.contextDescription?.trim() || null; @@ -1103,6 +1103,12 @@ export async function updateChatConversationContext( return getChatConversation(sessionId, normalizedClientId); } +export function resolveNextConversationChatTypeId(currentChatTypeId?: string | null, requestedChatTypeId?: string | null) { + const normalizedCurrentChatTypeId = String(currentChatTypeId ?? '').trim() || null; + const normalizedRequestedChatTypeId = String(requestedChatTypeId ?? '').trim() || null; + return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null; +} + export async function listChatConversations( clientId?: string | null, limit = 50, diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index a81dac2..409aed7 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -1469,7 +1469,9 @@ function buildAgenticCodexPrompt( '- 한국어로 간결하게 답하세요.', '', '채팅 유형 문맥(우선 적용):', - `- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`, + context?.chatTypeLabel && context.chatTypeLabel.trim() !== '일반 요청' + ? `- chatTypeLabel: ${context.chatTypeLabel}` + : '- chatTypeLabel: 없음', `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`, '- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.', '- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', @@ -1645,6 +1647,11 @@ async function runAgenticCodexReply( maxMessages: appConfig.chat?.maxContextMessages, maxChars: appConfig.chat?.maxContextChars, }); + const codexLiveMaxExecutionSeconds = + typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' && + Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds) + ? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds))) + : null; const prompt = buildAgenticCodexPrompt(context, input, sessionId, { recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, @@ -1697,6 +1704,7 @@ async function runAgenticCodexReply( prompt, resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), + maxExecutionSeconds: codexLiveMaxExecutionSeconds, }), }); diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 015cc4e..68370eb 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -505,6 +505,7 @@ async function runCodexLiveExecution(payload, response) { const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'); const uploadDir = path.join(resourceDir, 'uploads'); const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; + const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds); if (!requestId || !sessionId || !prompt.trim()) { sendJson(response, 400, { @@ -619,9 +620,9 @@ async function runCodexLiveExecution(payload, response) { executionTimer = setTimeout(() => { requestTermination( - `Codex Live 실행이 ${Math.round(CODEX_LIVE_MAX_EXECUTION_MS / 1000)}초를 넘어 중단되었습니다.`, + `Codex Live 실행이 ${Math.round(configuredMaxExecutionMs / 1000)}초를 넘어 중단되었습니다.`, ); - }, CODEX_LIVE_MAX_EXECUTION_MS); + }, configuredMaxExecutionMs); executionTimer.unref?.(); refreshIdleTimer(); @@ -758,6 +759,14 @@ async function runCodexLiveExecution(payload, response) { child.stdin?.end(prompt); } +function resolveCodexLiveMaxExecutionMs(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return CODEX_LIVE_MAX_EXECUTION_MS; + } + + return Math.max(CODEX_LIVE_IDLE_TIMEOUT_MS, Math.min(7_200_000, Math.max(60_000, Math.round(value * 1000)))); +} + function isAuthorized(request) { const token = String(request.headers['x-access-token'] ?? '').trim(); return token.length > 0 && token === accessToken; diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 618d408..d1422ed 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -458,7 +458,7 @@ export function ChatTypeManagementPage() { autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }} className="chat-type-management-page__markdown-textarea" placeholder={ - '## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준' + '## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 모바일 브라우저 캡처/검증 기준\n\n## 구현 기준\n- clean code 원칙' } /> diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index af77df8..0f97d81 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -201,6 +201,16 @@ color: #92400e; } +.app-chat-panel__conversation-section-header--failed .app-chat-panel__conversation-section-title { + color: #b91c1c; +} + +.app-chat-panel__conversation-section-header--failed .app-chat-panel__conversation-section-count { + background: linear-gradient(180deg, rgba(254, 202, 202, 0.98), rgba(252, 165, 165, 0.96)); + color: #7f1d1d; + box-shadow: 0 4px 12px rgba(220, 38, 38, 0.14); +} + .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-title { color: #1d4ed8; } @@ -274,6 +284,16 @@ 0 10px 24px rgba(245, 158, 11, 0.12); } +.app-chat-panel__conversation-item--failed { + border-color: rgba(220, 38, 38, 0.16); + background: + linear-gradient(90deg, rgba(254, 242, 242, 0.99), rgba(254, 226, 226, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%), + #fff; + box-shadow: + inset 4px 0 0 rgba(220, 38, 38, 0.9), + 0 10px 24px rgba(220, 38, 38, 0.1); +} + .app-chat-panel__conversation-item--unread { border-color: rgba(37, 99, 235, 0.18); background: @@ -294,6 +314,16 @@ 0 14px 30px rgba(37, 99, 235, 0.2); } +.app-chat-panel__conversation-item--failed-section { + border-color: rgba(220, 38, 38, 0.2); + background: + linear-gradient(135deg, rgba(254, 226, 226, 1), rgba(254, 242, 242, 0.99) 42%, rgba(255, 255, 255, 1) 84%), + #fff; + box-shadow: + inset 6px 0 0 rgba(220, 38, 38, 0.94), + 0 14px 30px rgba(220, 38, 38, 0.14); +} + .app-chat-panel__conversation-item--general { opacity: 0.94; } @@ -353,6 +383,16 @@ 0 12px 28px rgba(217, 119, 6, 0.16); } +.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--failed { + border-color: rgba(185, 28, 28, 0.46); + background: + linear-gradient(90deg, rgba(254, 226, 226, 1), rgba(254, 242, 242, 0.99) 28%, rgba(255, 255, 255, 1) 74%), + #fff; + box-shadow: + inset 4px 0 0 rgba(185, 28, 28, 0.96), + 0 12px 28px rgba(220, 38, 38, 0.14); +} + .app-chat-panel__conversation-item--processing.app-chat-panel__conversation-item--unread { border-color: rgba(147, 51, 234, 0.48); background: diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 0849b5b..5636449 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -39,6 +39,7 @@ import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrl import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; +import { createNotificationMessage } from './notificationApi'; import { useTokenAccess } from './tokenAccess'; import { ChatConversationView, @@ -113,6 +114,23 @@ type PendingContextConfirm = { const CHAT_MAX_RETRY_ATTEMPTS = 5; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; +const CHAT_RESTART_REQUIRED_PATTERNS = [ + /재기동(?:이|은)? 필요/, + /재시작(?:이|은)? 필요/, + /앱\s*재기동\s*필요/, + /서버\s*재기동\s*필요/, + /\brestart(?:ing)? (?:is )?required\b/i, + /\bneeds? (?:an? )?restart\b/i, + /\brestart (?:the|this) (?:app|server|service)\b/i, +] as const; +const CHAT_RESTART_EXCLUSION_PATTERNS = [ + /재기동(?:은|이)?\s*(?:하지|안 해|안해)/, + /재시작(?:은|이)?\s*(?:하지|안 해|안해)/, + /재기동\s*(?:불필요|없음)/, + /재시작\s*(?:불필요|없음)/, + /\bno restart\b/i, + /\bwithout restart\b/i, +] as const; function isStandaloneDisplayMode() { if (typeof window === 'undefined') { @@ -125,6 +143,35 @@ function isStandaloneDisplayMode() { ); } +function isRestartRequiredResponseText(text: string) { + const normalized = String(text ?? '') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) { + return false; + } + + if (CHAT_RESTART_EXCLUSION_PATTERNS.some((pattern) => pattern.test(normalized))) { + return false; + } + + return CHAT_RESTART_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function buildChatSessionLink(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId || typeof window === 'undefined') { + return ''; + } + + const url = new URL('/chat/live', window.location.origin); + url.searchParams.set('topMenu', 'chat'); + url.searchParams.set('sessionId', normalizedSessionId); + return `${url.pathname}${url.search}${url.hash}`; +} + function getCachedSessionMessages(cache: Map, sessionId: string) { const normalizedSessionId = sessionId.trim(); @@ -853,6 +900,10 @@ function isConversationProcessing(item: Pick) { + return item.currentJobStatus === 'failed'; +} + function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) { if (!snapshot) { return null; @@ -965,8 +1016,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting'); const shouldRestoreConversationAfterReconnectRef = useRef(false); const handledRequestedSessionIdRef = useRef(''); + const syncedSelectedChatTypeSessionIdRef = useRef(null); const isClosingConversationRef = useRef(false); const notifiedTerminalJobKeysRef = useRef([]); + const notifiedRestartRequirementKeysRef = useRef([]); const lastMarkedReadResponseIdBySessionRef = useRef>({}); const requestItems = Array.isArray(requestItemsState) ? requestItemsState : []; const setRequestItems = useCallback((next: SetStateAction) => { @@ -1265,12 +1318,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => { const sessionId = eventSessionId.trim() || activeSessionId; + let relatedQuestionText = ''; if (incomingMessage.clientRequestId) { const existing = requestItemsRef.current.find( (item) => item.sessionId === sessionId && item.requestId === incomingMessage.clientRequestId, ) ?? null; + relatedQuestionText = incomingMessage.author === 'codex' ? existing?.userText ?? '' : ''; const hasMeaningfulResponse = incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text); @@ -1371,6 +1426,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null; + if (incomingMessage.author === 'codex' && hasMeaningfulCodexResponse && isRestartRequiredResponseText(incomingMessage.text)) { + const restartNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:restart-required`; + + if (!notifiedRestartRequirementKeysRef.current.includes(restartNotificationKey)) { + notifiedRestartRequirementKeysRef.current = [ + ...notifiedRestartRequirementKeysRef.current, + restartNotificationKey, + ].slice(-80); + + const conversationTitle = eventConversation?.title?.trim() || '현재 채팅방'; + const answerPreview = createConversationPreviewText(incomingMessage.text); + + void createNotificationMessage({ + title: '앱 재기동 필요', + body: `${conversationTitle}에서 Codex가 앱 재기동 필요를 안내했습니다.\n답변: ${answerPreview}`, + category: 'chat', + source: 'codex-live', + priority: 'high', + metadata: { + sessionId, + requestId: incomingMessage.clientRequestId ?? '', + conversationTitle, + questionText: relatedQuestionText, + answerText: incomingMessage.text, + previewText: `재기동 필요 · ${conversationTitle}`, + needsRestart: true, + restartRequired: true, + linkUrl: buildChatSessionLink(sessionId), + linkLabel: '채팅 바로 열기', + }, + }).catch(() => undefined); + } + } + if (incomingMessage.author !== 'codex' || eventConversation?.notifyOffline !== true) { return; } @@ -1432,6 +1521,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const isEffectiveChatTypeAllowed = effectiveRegisteredChatType ? canUseChatType(effectiveRegisteredChatType, userRoles) : false; + const isChatTypeSelectionLocked = Boolean(activeSessionId); const currentContext: ChatViewContext = { pageId: currentPage.id, pageTitle: currentPage.title, @@ -1596,15 +1686,25 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ); }, [conversationItems, conversationSearch]); const unreadConversationItems = useMemo( - () => filteredConversationItems.filter((item) => item.hasUnreadResponse), + () => filteredConversationItems.filter((item) => item.hasUnreadResponse && !isConversationFailed(item)), + [filteredConversationItems], + ); + const failedConversationItems = useMemo( + () => filteredConversationItems.filter((item) => isConversationFailed(item)), [filteredConversationItems], ); const processingConversationItems = useMemo( - () => filteredConversationItems.filter((item) => isConversationProcessing(item) && !item.hasUnreadResponse), + () => + filteredConversationItems.filter( + (item) => isConversationProcessing(item) && !item.hasUnreadResponse && !isConversationFailed(item), + ), [filteredConversationItems], ); const generalConversationItems = useMemo( - () => filteredConversationItems.filter((item) => !item.hasUnreadResponse && !isConversationProcessing(item)), + () => + filteredConversationItems.filter( + (item) => !item.hasUnreadResponse && !isConversationProcessing(item) && !isConversationFailed(item), + ), [filteredConversationItems], ); const pendingDeleteConversation = @@ -1847,11 +1947,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const renderConversationListItem = ( item: ChatConversationSummary, - section: 'processing' | 'unread' | 'general' = 'general', + section: 'failed' | 'processing' | 'unread' | 'general' = 'general', ) => { const isUnread = item.hasUnreadResponse; const isProcessing = isConversationProcessing(item); + const isFailed = isConversationFailed(item); const isUnreadSection = section === 'unread'; + const isFailedSection = section === 'failed'; return (
item.id === selectedChatTypeId)) { @@ -2331,15 +2440,30 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]); useEffect(() => { - if (!activeSessionId || !selectedChatTypeId || !selectedChatType) { + if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) { return; } const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null; - if (currentChatTypeId) { + const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null; + if (currentChatTypeId === selectedChatTypeId && currentLastChatTypeId === selectedChatTypeId) { return; } + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === activeSessionId + ? { + ...entry, + chatTypeId: selectedChatTypeId, + lastChatTypeId: selectedChatTypeId, + contextLabel: selectedChatType.name, + contextDescription: selectedChatType.description, + } + : entry, + ), + ); + void chatGateway.updateConversation(activeSessionId, { chatTypeId: selectedChatTypeId, lastChatTypeId: selectedChatTypeId, @@ -2349,13 +2473,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)), ); - }).catch(() => { - // Ignore background sync failures and keep local in-memory fallback. + }).catch((error: unknown) => { + messageApi.error(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.'); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === activeSessionId && activeConversation + ? { + ...entry, + chatTypeId: activeConversation.chatTypeId, + lastChatTypeId: activeConversation.lastChatTypeId, + contextLabel: activeConversation.contextLabel, + contextDescription: activeConversation.contextDescription, + } + : entry, + ), + ); + setSelectedChatTypeId(activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null); }); }, [ activeConversation?.chatTypeId, + activeConversation?.contextDescription, + activeConversation?.contextLabel, activeConversation?.lastChatTypeId, activeSessionId, + isChatTypeSelectionLocked, + messageApi, selectedChatType, selectedChatTypeId, setConversationItems, @@ -2914,6 +3056,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
{filteredConversationItems.length > 0 ? ( <> + {failedConversationItems.length > 0 ? ( +
+
+ 요청 처리 실패 + + {failedConversationItems.length} + +
+ {failedConversationItems.map((item) => renderConversationListItem(item, 'failed'))} +
+ ) : null} {unreadConversationItems.length > 0 ? (
@@ -2938,7 +3091,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ) : null} {generalConversationItems.length > 0 ? (
- {processingConversationItems.length > 0 || unreadConversationItems.length > 0 ? ( + {failedConversationItems.length > 0 || + processingConversationItems.length > 0 || + unreadConversationItems.length > 0 ? (
전체 대화 @@ -2988,7 +3143,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))} isResourceStripOpen={isResourceStripOpen} isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed} - isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())} + isChatTypeSelectionLocked={isChatTypeSelectionLocked} isComposerAttachmentUploading={isComposerAttachmentUploading} onViewportScroll={handleViewportScroll} onViewportTouchEnd={handleViewportTouchEnd} @@ -3000,7 +3155,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId)); }} onSelectChatType={(nextChatTypeId) => { - if (activeConversation?.chatTypeId?.trim()) { + if (isChatTypeSelectionLocked) { return; } diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 566989a..00fe3be 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -116,7 +116,11 @@ type InlineFeedback = { }; function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { - return left.maxContextMessages === right.maxContextMessages && left.maxContextChars === right.maxContextChars; + return ( + left.maxContextMessages === right.maxContextMessages && + left.maxContextChars === right.maxContextChars && + left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds + ); } function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['chat']) { @@ -130,6 +134,10 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c changedLabels.push('최근 문맥 글자 수'); } + if (saved.codexLiveMaxExecutionSeconds !== draft.codexLiveMaxExecutionSeconds) { + changedLabels.push('Codex Live 최대 실행 시간'); + } + return changedLabels; } @@ -2136,8 +2144,8 @@ export function MainHeader({ message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'} description={ chatSettingsDirty - ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자` - : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조` + ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초` + : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초까지 허용` } /> @@ -2185,6 +2193,29 @@ export function MainHeader({ }} />
+ +
+ Codex Live 최대 실행 시간(초) + Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다. + { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + codexLiveMaxExecutionSeconds: + typeof value === 'number' && Number.isFinite(value) + ? Math.min(7200, Math.max(60, Math.round(value))) + : DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds, + }, + })); + }} + /> +
); diff --git a/src/app/main/appConfig.ts b/src/app/main/appConfig.ts index f719afe..8c98557 100755 --- a/src/app/main/appConfig.ts +++ b/src/app/main/appConfig.ts @@ -18,6 +18,7 @@ export type AppConfig = { chat: { maxContextMessages: number; maxContextChars: number; + codexLiveMaxExecutionSeconds: number; }; automation: { autoRefreshEnabled: boolean; @@ -70,6 +71,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = { chat: { maxContextMessages: 12, maxContextChars: 3200, + codexLiveMaxExecutionSeconds: 600, }, automation: { autoRefreshEnabled: true, @@ -243,6 +245,14 @@ function normalizeChatContextCharLimit(value: number | undefined, fallback: numb return Math.min(20_000, Math.max(500, Math.round(value))); } +function normalizeCodexLiveMaxExecutionSeconds(value: number | undefined, fallback: number) { + if (value === undefined || !Number.isFinite(value)) { + return fallback; + } + + return Math.min(7200, Math.max(60, Math.round(value))); +} + function normalizeConfig(raw?: Partial): AppConfig { const chat = raw?.chat; const automation = raw?.automation; @@ -258,6 +268,10 @@ function normalizeConfig(raw?: Partial): AppConfig { DEFAULT_APP_CONFIG.chat.maxContextMessages, ), maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars), + codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds( + chat?.codexLiveMaxExecutionSeconds, + DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds, + ), }, automation: { autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled, diff --git a/src/app/main/chatTypeAccess.ts b/src/app/main/chatTypeAccess.ts index 55cd819..0a6b316 100755 --- a/src/app/main/chatTypeAccess.ts +++ b/src/app/main/chatTypeAccess.ts @@ -33,7 +33,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ id: 'general-request', name: '일반 요청', description: - '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.', + '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', permissions: ['token-user'], enabled: true, updatedAt: '2026-04-21T00:00:00.000Z', diff --git a/src/app/main/chatV2/components/ConversationRoomPane.tsx b/src/app/main/chatV2/components/ConversationRoomPane.tsx index c66a1a0..0ffaab7 100644 --- a/src/app/main/chatV2/components/ConversationRoomPane.tsx +++ b/src/app/main/chatV2/components/ConversationRoomPane.tsx @@ -1,14 +1,35 @@ import { CodeOutlined, + CloseOutlined, CopyOutlined, + DeleteOutlined, DownloadOutlined, DownOutlined, + ExclamationCircleOutlined, FullscreenExitOutlined, FullscreenOutlined, + MessageOutlined, + PaperClipOutlined, + PlusOutlined, + RedoOutlined, + SendOutlined, + SyncOutlined, + ThunderboltOutlined, UpOutlined, } from '@ant-design/icons'; -import { Button, Empty, Spin, Typography, message as antdMessage } from 'antd'; -import { useEffect, useState, type ReactNode } from 'react'; +import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd'; +import type { TextAreaRef } from 'antd/es/input/TextArea'; +import { + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type ReactNode, + type RefObject, + type TouchEvent, +} from 'react'; import { InlineImage } from '../../../../components/common/InlineImage'; import { CodexDiffBlock } from '../../../../components/previewer'; import { @@ -16,30 +37,65 @@ import { resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind, - type ChatPreviewTarget, } from '../../mainChatPanel/ChatPreviewBody'; import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls'; import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers'; import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl'; import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils'; -import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types'; +import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; +const KST_TIME_ZONE = 'Asia/Seoul'; const { Text } = Typography; +const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; +const KST_DATE_TIME_FORMATTER = 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, +}); -type ConversationRoomPaneProps = { - sessionId: string; - messages: ChatMessage[]; - requests: ChatConversationRequest[]; - isLoading: boolean; - loadingLabel: string; - errorMessage: string; +type ChatTypeOption = { + value: string; + label: string; + description: string; + disabled?: boolean; +}; + +type PreviewOption = { + id: string; + label: string; + url: string; + kind: string; +}; + +type QueuedRequestOption = { + requestId: string; + order: number; + text: string; +}; + +type InlinePreviewKind = ChatPreviewKind; + +type InlinePreviewTarget = { + url: string; + label: string; + kind: InlinePreviewKind; +}; + +type PreviewFetchError = Error & { + status?: number; }; const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; -const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; +const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; +const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; type MessageRenderPayload = { previewSourceText: string; @@ -47,34 +103,11 @@ type MessageRenderPayload = { diffBlocks: string[]; }; -function formatChatTimestamp(timestamp: string) { - const normalized = String(timestamp ?? '').trim(); - - if (!normalized) { - return ''; - } - - const parsed = new Date(normalized); - - if (Number.isNaN(parsed.getTime())) { - return normalized; - } - - return new Intl.DateTimeFormat('sv-SE', { - timeZone: 'Asia/Seoul', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }) - .format(parsed) - .replace(',', ''); +function normalizeInlinePreviewUrl(value: string) { + return normalizeChatResourceUrl(value); } -function classifyInlinePreviewKind(url: string): ChatPreviewKind | 'file' { +function classifyInlinePreviewKind(url: string): InlinePreviewKind { const pathname = url.toLowerCase().split('?')[0] ?? ''; if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { @@ -108,15 +141,6 @@ function classifyInlinePreviewKind(url: string): ChatPreviewKind | 'file' { return 'file'; } -function buildInlinePreviewLabel(url: string) { - try { - const parsed = new URL(url); - return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname; - } catch { - return url; - } -} - function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') { if (typeof document === 'undefined') { return; @@ -133,16 +157,83 @@ function downloadTextFile(content: string, fileName: string, mimeType = 'text/pl URL.revokeObjectURL(objectUrl); } -function extractInlinePreviewTargets(text: string): ChatPreviewTarget[] { +function buildInlinePreviewLabel(url: string) { + try { + const parsed = new URL(url); + return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname; + } catch { + return url; + } +} + +function buildPreviewFileName(item: PreviewOption) { + try { + const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); + const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim(); + return fileName || item.label.trim() || item.url; + } catch { + return item.label.trim() || item.url; + } +} + +function normalizePreviewOptionKind(kind: string): ChatPreviewKind { + switch (kind) { + case 'image': + case 'video': + case 'markdown': + case 'code': + case 'diff': + case 'document': + case 'pdf': + return kind; + default: + return 'file'; + } +} + +async function createPreviewFetchError(response: Response): Promise { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + let responseMessage = ''; + + try { + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { message?: string }; + responseMessage = String(payload.message ?? '').trim(); + } else { + responseMessage = (await response.text()).trim(); + } + } catch { + responseMessage = ''; + } + + const statusLabel = + response.status === 403 + ? '이 문서는 현재 권한으로 열 수 없습니다.' + : response.status === 404 + ? '이 문서를 찾을 수 없습니다.' + : response.status === 401 + ? '이 문서를 열기 위한 인증이 필요합니다.' + : `preview 요청이 실패했습니다. (${response.status})`; + const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim(); + const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as PreviewFetchError; + error.status = response.status; + return error; +} + +function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)]; const seen = new Set(); - const targets: ChatPreviewTarget[] = []; + const targets: InlinePreviewTarget[] = []; for (const matchedUrl of matches) { - const normalizedUrl = normalizeChatResourceUrl(matchedUrl); + const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl); const kind = classifyInlinePreviewKind(normalizedUrl); - if (kind === 'file' || seen.has(normalizedUrl)) { + if (kind === 'file') { + continue; + } + + if (seen.has(normalizedUrl)) { continue; } @@ -169,7 +260,7 @@ function renderMessageInlineParts(line: string): ReactNode[] { renderedParts.push(line.slice(cursor, start)); } - const href = normalizeChatResourceUrl(rawHref.trim()); + const href = normalizeInlinePreviewUrl(rawHref.trim()); renderedParts.push( {label.trim() || href} @@ -186,12 +277,14 @@ function renderMessageInlineParts(line: string): ReactNode[] { } function renderMessageBody(text: string) { - return text.split('\n').map((line, index) => { + const lines = text.split('\n'); + + return lines.map((line, index) => { const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN); if (imageMatch) { const [, alt, rawSrc] = imageMatch; - const src = normalizeChatResourceUrl(rawSrc.trim()); + const src = normalizeInlinePreviewUrl(rawSrc.trim()); return (
@@ -225,7 +318,33 @@ function extractMessageRenderPayload(text: string): MessageRenderPayload { const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, ''); const visibleText = stripHiddenPreviewTags(previewSourceText); - return { previewSourceText, visibleText, diffBlocks }; + return { + previewSourceText, + visibleText, + diffBlocks, + }; +} + +function summarizeQueuedText(text: string) { + const normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized; +} + +function isActivityLogMessage(message: ChatMessage) { + return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); +} + +function extractActivityLines(message: ChatMessage) { + return message.text + .slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length) + .split('\n\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function summarizeActivityLines(lines: string[]) { + const latestLine = lines.at(-1) ?? ''; + return latestLine; } function isLikelyCollapsibleMessage(text: string) { @@ -239,81 +358,146 @@ function isLikelyCollapsibleMessage(text: string) { return true; } - return normalizedText + const visualLines = normalizedText .split('\n') .map((line) => line.trim()) - .filter(Boolean).length > COLLAPSIBLE_MESSAGE_LINE_COUNT; + .filter(Boolean); + + return visualLines.length > COLLAPSIBLE_MESSAGE_LINE_COUNT; } -async function createPreviewFetchError(response: Response) { - const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; - let responseMessage = ''; +function formatChatTimestamp(timestamp: string) { + const normalized = String(timestamp ?? '').trim(); - try { - responseMessage = contentType.includes('application/json') - ? String(((await response.json()) as { message?: string }).message ?? '').trim() - : (await response.text()).trim(); - } catch { - responseMessage = ''; + if (!normalized) { + return ''; } - const statusLabel = - response.status === 403 - ? '이 문서는 현재 권한으로 열 수 없습니다.' - : response.status === 404 - ? '이 문서를 찾을 수 없습니다.' - : response.status === 401 - ? '이 문서를 열기 위한 인증이 필요합니다.' - : `preview 요청이 실패했습니다. (${response.status})`; + if (KST_TIMESTAMP_PATTERN.test(normalized)) { + return normalized; + } - return new Error(responseMessage ? `${statusLabel} ${responseMessage}` : statusLabel); + const parsed = new Date(normalized); + + if (Number.isNaN(parsed.getTime())) { + return normalized; + } + + return KST_DATE_TIME_FORMATTER.format(parsed).replace(',', ''); +} + +function formatRequestStatusLabel(request: ChatConversationRequest | undefined) { + switch (request?.status) { + case 'accepted': + return '접수됨'; + case 'queued': + return '대기중'; + case 'started': + return request.hasResponse ? '응답작성중' : '처리중'; + case 'completed': + return '완료'; + case 'failed': + return '실패'; + case 'cancelled': + return '취소됨'; + case 'removed': + return '삭제됨'; + default: + return null; + } +} + +function isTerminalRequestStatus(status: ChatConversationRequest['status'] | undefined) { + return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed'; +} + +function getRequestDetailText(request: ChatConversationRequest | undefined) { + if (!request) { + return ''; + } + + const normalizedStatusMessage = String(request.statusMessage ?? '').trim(); + + if (!normalizedStatusMessage) { + return ''; + } + + if (request.status === 'failed') { + return normalizedStatusMessage.startsWith('실패') + ? normalizedStatusMessage + : `실패 사유: ${normalizedStatusMessage}`; + } + + if (request.status === 'cancelled') { + return normalizedStatusMessage.startsWith('취소') + ? normalizedStatusMessage + : `취소 사유: ${normalizedStatusMessage}`; + } + + if (request.status === 'removed') { + return normalizedStatusMessage; + } + + return ''; } function InlineMessagePreview({ target, isExpanded, + hasModalPreview, + onOpenModalPreview, onToggle, }: { - target: ChatPreviewTarget; + target: InlinePreviewTarget; isExpanded: boolean; + hasModalPreview: boolean; + onOpenModalPreview: () => void; onToggle: () => void; }) { - const [previewText, setPreviewText] = useState(''); - const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [textPreview, setTextPreview] = useState(''); + const [isLoading, setIsLoading] = useState(false); const [previewError, setPreviewError] = useState(''); const [previewContentType, setPreviewContentType] = useState(''); useEffect(() => { - if (!isExpanded || target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') { + if (!isExpanded) { + return; + } + + if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') { return; } const controller = new AbortController(); - setIsPreviewLoading(true); + setIsLoading(true); setPreviewError(''); setPreviewContentType(''); - fetch(target.url, { cache: 'no-store', signal: controller.signal }) + fetch(target.url, { + cache: 'no-store', + signal: controller.signal, + }) .then(async (response) => { if (!response.ok) { throw await createPreviewFetchError(response); } setPreviewContentType(response.headers.get('content-type') ?? ''); - setPreviewText((await response.text()).slice(0, 1600)); + const text = await response.text(); + setTextPreview(text.slice(0, 1600)); }) .catch((error: unknown) => { if (controller.signal.aborted) { return; } - setPreviewText(''); + setTextPreview(''); setPreviewContentType(''); setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.'); }) .finally(() => { if (!controller.signal.aborted) { - setIsPreviewLoading(false); + setIsLoading(false); } }); @@ -322,6 +506,30 @@ function InlineMessagePreview({ }; }, [isExpanded, target.kind, target.url]); + const handleCopyPreview = () => { + void copyPreviewContent({ + kind: target.kind, + url: target.url, + fallbackText: textPreview, + }) + .then((result) => { + if (result === 'image') { + message.success('preview 이미지를 복사했습니다.'); + return; + } + + if (result === 'url') { + message.success('preview 이미지 URL을 복사했습니다.'); + return; + } + + message.success('preview 내용을 복사했습니다.'); + }) + .catch((error: unknown) => { + message.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.'); + }); + }; + return (
@@ -341,37 +549,24 @@ function InlineMessagePreview({ className="app-chat-preview-card__action" icon={} aria-label="preview 내용 복사" - onClick={() => { - void copyPreviewContent({ - kind: target.kind, - url: target.url, - fallbackText: previewText, - }) - .then((result) => { - if (result === 'image') { - antdMessage.success('preview 이미지를 복사했습니다.'); - return; - } - - if (result === 'url') { - antdMessage.success('preview 이미지 URL을 복사했습니다.'); - return; - } - - antdMessage.success('preview 내용을 복사했습니다.'); - }) - .catch((error: unknown) => - antdMessage.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.'), - ); - }} + onClick={handleCopyPreview} /> +
+
+
+ 현재 상태 + {liveStatusLine} +
+
+ {isExpanded ? ( +
+
+ {lines.map((line, lineIndex) => ( +
+ +

{line}

+
+ ))} +
+
+ ) : null} +
+
+ ); + }; return ( -
-
-
- {sessionId} -
- - 메시지 {messages.length}개 · 요청 {requests.length}개 - +
+ {isConversationLoading ? ( +
+ + {conversationLoadingLabel} + 재접속 중에는 채팅방 내용을 다시 맞춘 뒤 자연스럽게 표시합니다.
-
+ ) : null} -
- {messages.length === 0 ? ( -
- + {showBusyOverlay ? ( +
+ + {busyOverlayLabel} + 처리가 끝나면 화면이 바로 갱신됩니다. +
+ ) : null} + +
+
+
+ + {isResourceStripOpen ? ( +
+ {previewItems.length > 0 ? ( + <> + +
+ {visiblePreviewItems.map((item) => ( + { + onOpenPreview(item.id, { fullscreen: true }); + }} + onToggle={() => { + setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id)); + }} + /> + ))} +
+ + ) : ( + + 현재 대화에 바로 열 수 있는 리소스가 없습니다. + + )}
- ) : ( - messages.map((message) => { - const canCollapseMessage = isLikelyCollapsibleMessage(message.text); - const isExpandedMessage = expandedMessageIds.includes(message.id); - const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; - const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; - const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text); - const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText); - const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; - const shouldRenderStandalonePreview = - hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); - const stackClassName = [ - `app-chat-message-stack app-chat-message-stack--${message.author}`, - shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '', - ] - .filter(Boolean) - .join(' '); + ) : null} - return ( -
- {shouldRenderStandalonePreview ? null : ( -
-
-
- {message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'} - {formatChatTimestamp(message.timestamp)} -
- {message.author !== 'system' ? ( +
+ {hasOlderMessages || isLoadingOlderMessages || pullToLoadDistance > 0 ? ( +
0 ? 1 : 0.72, + }} + > + + + {isLoadingOlderMessages + ? '이전 대화를 동기화하는 중입니다.' + : isPullToLoadArmed + ? '손을 놓으면 이전 대화를 더 불러옵니다.' + : hasOlderMessages + ? '최상단에서 아래로 끌어당겨 이전 대화를 불러오세요.' + : '이전 대화가 없습니다.'} + +
+ ) : null} + {orderedMessages.map((message) => { + const canCollapseMessage = collapsibleMessageIds.includes(message.id); + const isExpandedMessage = expandedMessageIds.includes(message.id); + const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; + const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; + const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text); + + if (isActivityLogMessage(message)) { + return renderActivityCard(message); + } + + const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText); + const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; + const shouldRenderStandalonePreview = + hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); + const stackClassName = [ + `app-chat-message-stack app-chat-message-stack--${message.author}`, + shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '', + ] + .filter(Boolean) + .join(' '); + const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined; + const requestStatusLabel = formatRequestStatusLabel(requestState); + const requestDetailText = getRequestDetailText(requestState); + + return ( +
+ {shouldRenderStandalonePreview ? null : ( +
+
+
+ {message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'} + {formatChatTimestamp(message.timestamp)} + {message.author === 'user' && requestStatusLabel ? ( + + {requestStatusLabel} + + ) : null} + {message.author === 'user' && message.deliveryStatus === 'retrying' ? ( + + + {message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'} + + ) : null} + {message.author === 'user' && message.deliveryStatus === 'failed' ? ( + + + 전송 실패 + + ) : null} + {message.author === 'user' && + (message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? ( + ) : null} + {message.author === 'user' && message.deliveryStatus === 'failed' ? ( + <> + + + ) : null} + {message.author === 'user' && + requestState?.canDelete && + requestState.status !== 'accepted' ? ( +
-
{visibleText ? renderMessageBody(visibleText) : null}
- {canCollapseMessage ? ( + {message.author !== 'system' ? ( + /> ) : null} -
- )} - {hasPreviewCards ? ( -
- {diffBlocks.map((diffText, index) => { - const previewKey = `${message.id}-diff-${index}`; +
+
{ + setMessageBodyRef(message.id, element); + }} + className={messageBodyClassName} + > + {visibleText ? renderMessageBody(visibleText) : null} +
+ {message.author === 'user' && requestDetailText ? ( +
+ {requestDetailText} +
+ ) : null} + {canCollapseMessage ? ( + + ) : null} +
+ )} + {hasPreviewCards ? ( +
+ {diffBlocks.map((diffText, index) => { + const previewKey = `${message.id}-diff-${index}`; return ( ); - })} - {inlinePreviewTargets.map((target) => { - const previewKey = `${message.id}-${target.url}`; - return ( - { - setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey)); - }} - /> - ); - })} -
- ) : null} -
- ); - }) - )} + })} + {inlinePreviewTargets.map((target) => { + const previewKey = `${message.id}-${target.url}`; + const matchedPreview = previewItemsByUrl.get(target.url); + + return ( + { + if (matchedPreview) { + onOpenPreview(matchedPreview.id, { fullscreen: true }); + return; + } + }} + onToggle={() => { + setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey)); + }} + /> + ); + })} +
+ ) : null} +
+ ); + })} +
-
+ +
+
+ {activeSystemStatus ?? ''} + {activeSystemStatus && isSystemStatusPending ? ( +
+ + + +
+ ) : null} +
+
+ + {showScrollToBottom ? ( +
+
+ ) : null} + +
+
+
+
+
+ +
+ + {composerAttachments.length > 0 ? ( +
+ {composerAttachments.map((attachment) => ( +
+ {attachment.name} +
+ ))} +
+ ) : null} +
+
+
); } diff --git a/src/app/main/mainChatPanel/ChatConversationView.tsx b/src/app/main/mainChatPanel/ChatConversationView.tsx index cb1f500..d97fda9 100755 --- a/src/app/main/mainChatPanel/ChatConversationView.tsx +++ b/src/app/main/mainChatPanel/ChatConversationView.tsx @@ -17,7 +17,7 @@ import { ThunderboltOutlined, UpOutlined, } from '@ant-design/icons'; -import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd'; +import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { useEffect, @@ -40,6 +40,7 @@ import { copyPreviewContent, copyText } from './chatUtils'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; const KST_TIME_ZONE = 'Asia/Seoul'; +const { Text } = Typography; const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', { timeZone: KST_TIME_ZONE, @@ -840,6 +841,22 @@ export function ChatConversationView({ return [...ordered, ...orphanActivityMessages]; }, [visibleMessages]); const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]); + const selectedChatTypeOption = useMemo( + () => chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null, + [chatTypeOptions, selectedChatTypeId], + ); + const normalizedSelectedChatTypeLabel = selectedChatTypeOption?.label?.trim() ?? ''; + const isChatTypeReadonly = useMemo(() => { + if (isChatTypeSelectionLocked) { + return true; + } + + if (typeof window === 'undefined') { + return false; + } + + return Boolean(new URLSearchParams(window.location.search).get('sessionId')?.trim()); + }, [isChatTypeSelectionLocked]); const visiblePreviewItems = useMemo(() => { if (!showLatestResourceOnly) { return previewItems; @@ -1524,9 +1541,14 @@ export function ChatConversationView({ ), }))} getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} - disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked} + disabled={chatTypeOptions.length === 0 || isChatTypeReadonly} onChange={onSelectChatType} /> + {normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? ( + + 현재 채팅유형: {normalizedSelectedChatTypeLabel} + + ) : null}
diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index bf7bc21..d6746cc 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -514,7 +514,11 @@ export function appendActivityEventToMessages(previous: ChatMessage[], event: Ch } export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?: string) { - const contextLabelLine = chatTypeLabel ? `선택 컨텍스트: ${chatTypeLabel}` : ''; + const normalizedChatTypeLabel = chatTypeLabel?.trim() ?? ''; + const contextLabelLine = + normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' + ? `선택 컨텍스트: ${normalizedChatTypeLabel}` + : ''; const contextDescriptionLine = chatTypeDescription ? `기본 문맥: ${chatTypeDescription}` : ''; return createChatMessage( @@ -525,7 +529,11 @@ export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?: export function buildOfflineReply(context: ChatViewContext, input: string) { const normalized = input.toLowerCase(); - const typeLine = context.chatTypeLabel ? `- 컨텍스트: ${context.chatTypeLabel}` : ''; + const normalizedChatTypeLabel = context.chatTypeLabel?.trim() ?? ''; + const typeLine = + normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' + ? `- 컨텍스트: ${normalizedChatTypeLabel}` + : ''; const descriptionLine = context.chatTypeDescription ? `- 기본 문맥: ${context.chatTypeDescription}` : ''; if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {