feat: refine codex live chat flow

This commit is contained in:
2026-04-24 21:02:01 +09:00
parent d53532508b
commit 63e5d263a7
18 changed files with 1747 additions and 297 deletions

View File

@@ -1,6 +1,7 @@
.git
.auto_codex
.docker
etc/servers/work-server/.docker
.idea
.vscode
node_modules

View File

@@ -45,7 +45,7 @@ prepare_runtime() {
start_child() {
log "starting server process"
npm run start &
node dist/server.js &
CHILD_PID=$!
}

View File

@@ -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({

View File

@@ -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'));
});

View File

@@ -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/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/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<string, unknown>;
}
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<AppConfigSnapshot> {
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<string, unknown>) {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,
}),
});

View File

@@ -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;

View File

@@ -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 원칙'
}
/>
</Form.Item>

View File

@@ -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:

View File

@@ -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<string, ChatMessage[]>, sessionId: string) {
const normalizedSessionId = sessionId.trim();
@@ -853,6 +900,10 @@ function isConversationProcessing(item: Pick<ChatConversationSummary, 'currentJo
return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
}
function isConversationFailed(item: Pick<ChatConversationSummary, 'currentJobStatus'>) {
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<string | null>(null);
const isClosingConversationRef = useRef(false);
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
@@ -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 (
<div
@@ -1860,6 +1962,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
item.sessionId === activeSessionId ? ' app-chat-panel__conversation-item--active' : ''
}${isUnread ? ' app-chat-panel__conversation-item--unread' : ''}${
isProcessing ? ' app-chat-panel__conversation-item--processing' : ''
}${isFailed ? ' app-chat-panel__conversation-item--failed' : ''}${
isFailedSection ? ' app-chat-panel__conversation-item--failed-section' : ''
}${isUnreadSection ? ' app-chat-panel__conversation-item--unread-section' : ''}${
section === 'general' ? ' app-chat-panel__conversation-item--general' : ''
}`}
@@ -2312,15 +2416,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
const persistedChatTypeId =
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
if (syncedSelectedChatTypeSessionIdRef.current !== activeSessionId) {
syncedSelectedChatTypeSessionIdRef.current = activeSessionId;
const persistedChatTypeId =
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
if (persistedChatTypeId) {
if (selectedChatTypeId !== persistedChatTypeId) {
setSelectedChatTypeId(persistedChatTypeId);
if (persistedChatTypeId) {
if (selectedChatTypeId !== persistedChatTypeId) {
setSelectedChatTypeId(persistedChatTypeId);
}
return;
}
return;
}
} else {
syncedSelectedChatTypeSessionIdRef.current = null;
}
if (selectedChatTypeId && availableChatTypes.some((item) => 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 =
<div className="app-chat-panel__conversation-list-body">
{filteredConversationItems.length > 0 ? (
<>
{failedConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section">
<div className="app-chat-panel__conversation-section-header app-chat-panel__conversation-section-header--failed">
<span className="app-chat-panel__conversation-section-title"> </span>
<span className="app-chat-panel__conversation-section-count">
{failedConversationItems.length}
</span>
</div>
{failedConversationItems.map((item) => renderConversationListItem(item, 'failed'))}
</div>
) : null}
{unreadConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section">
<div className="app-chat-panel__conversation-section-header app-chat-panel__conversation-section-header--unread">
@@ -2938,7 +3091,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
) : null}
{generalConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section">
{processingConversationItems.length > 0 || unreadConversationItems.length > 0 ? (
{failedConversationItems.length > 0 ||
processingConversationItems.length > 0 ||
unreadConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section-header app-chat-panel__conversation-section-header--muted">
<span className="app-chat-panel__conversation-section-title"> </span>
<span className="app-chat-panel__conversation-section-count">
@@ -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;
}

View File

@@ -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({
}}
/>
</div>
<div>
<Text strong>Codex Live ()</Text>
<Paragraph type="secondary">Codex Live 1 .</Paragraph>
<InputNumber
min={60}
max={7200}
step={30}
value={appConfigDraft.chat.codexLiveMaxExecutionSeconds}
onChange={(value) => {
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,
},
}));
}}
/>
</div>
</Space>
);

View File

@@ -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>): AppConfig {
const chat = raw?.chat;
const automation = raw?.automation;
@@ -258,6 +268,10 @@ function normalizeConfig(raw?: Partial<AppConfig>): 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,

View File

@@ -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/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/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',

File diff suppressed because it is too large Load Diff

View File

@@ -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 !== '일반 요청' ? (
<Text type="secondary" className="app-chat-panel__composer-type-note">
: {normalizedSelectedChatTypeLabel}
</Text>
) : null}
</div>
<div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons">

View File

@@ -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')) {