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 .git
.auto_codex .auto_codex
.docker .docker
etc/servers/work-server/.docker
.idea .idea
.vscode .vscode
node_modules node_modules

View File

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

View File

@@ -1,6 +1,12 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; 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'; import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) { export async function registerAppConfigRoutes(app: FastifyInstance) {
@@ -9,7 +15,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
config: config ?? {}, config: normalizeAppConfigSnapshot(config),
}; };
}); });
@@ -115,7 +121,7 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
config: savedConfig, config: normalizeAppConfigSnapshot(savedConfig),
}; };
} catch (error) { } catch (error) {
return reply.code(409).send({ 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'; export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
} as const;
type ChatPermissionRole = 'guest' | 'token-user'; type ChatPermissionRole = 'guest' | 'token-user';
@@ -19,7 +24,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request', id: 'general-request',
name: '일반 요청', name: '일반 요청',
description: 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'], permissions: ['token-user'],
enabled: true, enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-23T00:00:00.000Z',
@@ -98,6 +103,14 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, 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) { function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''; 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')); 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 savedItems = sanitizeChatTypes(items);
const byId = new Map(savedItems.map((item) => [item.id, item] as const)); const byId = new Map(savedItems.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_CHAT_TYPES) { for (const defaultItem of DEFAULT_CHAT_TYPES) {
const existingItem = byId.get(defaultItem.id); if (!byId.has(defaultItem.id)) {
if (!existingItem) {
byId.set(defaultItem.id, defaultItem); 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())); return sanitizeChatTypes(Array.from(byId.values()));
@@ -230,6 +233,7 @@ export type AppConfigSnapshot = {
chat?: { chat?: {
maxContextMessages?: number; maxContextMessages?: number;
maxContextChars?: number; maxContextChars?: number;
codexLiveMaxExecutionSeconds?: number;
}; };
automation?: { automation?: {
autoRefreshEnabled?: boolean; 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> { export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
const raw = await getAppConfig(); return normalizeAppConfigSnapshot(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;
} }
export async function upsertAppConfig(config: Record<string, unknown>) { export async function upsertAppConfig(config: Record<string, unknown>) {

View File

@@ -4,6 +4,7 @@ import {
buildChatConversationRequestPatchFromMessage, buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage, isVisibleConversationMessage,
mergeChatConversationRequestStatus, mergeChatConversationRequestStatus,
resolveNextConversationChatTypeId,
shouldClearConversationJobState, shouldClearConversationJobState,
selectChatConversationResponseCandidate, selectChatConversationResponseCandidate,
} from './chat-room-service.js'; } from './chat-room-service.js';
@@ -15,6 +16,17 @@ test('mergeChatConversationRequestStatus keeps terminal states from being downgr
assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed'); 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', () => { test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
assert.equal( assert.equal(
buildChatConversationRequestPatchFromMessage({ buildChatConversationRequestPatchFromMessage({

View File

@@ -1072,7 +1072,7 @@ export async function updateChatConversationContext(
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null; const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
const requestedChatTypeId = payload.chatTypeId?.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 requestedContextLabel = payload.contextLabel?.trim() || null;
const requestedContextDescription = payload.contextDescription?.trim() || null; const requestedContextDescription = payload.contextDescription?.trim() || null;
@@ -1103,6 +1103,12 @@ export async function updateChatConversationContext(
return getChatConversation(sessionId, normalizedClientId); 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( export async function listChatConversations(
clientId?: string | null, clientId?: string | null,
limit = 50, 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 ?? '없음'}`, `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.', '- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', '- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
@@ -1645,6 +1647,11 @@ async function runAgenticCodexReply(
maxMessages: appConfig.chat?.maxContextMessages, maxMessages: appConfig.chat?.maxContextMessages,
maxChars: appConfig.chat?.maxContextChars, 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, { const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
recentHistoryLines: recentHistory.items, recentHistoryLines: recentHistory.items,
omittedHistoryCount: recentHistory.omittedCount, omittedHistoryCount: recentHistory.omittedCount,
@@ -1697,6 +1704,7 @@ async function runAgenticCodexReply(
prompt, prompt,
resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'),
uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), 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 resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
const uploadDir = path.join(resourceDir, 'uploads'); const uploadDir = path.join(resourceDir, 'uploads');
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; 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()) { if (!requestId || !sessionId || !prompt.trim()) {
sendJson(response, 400, { sendJson(response, 400, {
@@ -619,9 +620,9 @@ async function runCodexLiveExecution(payload, response) {
executionTimer = setTimeout(() => { executionTimer = setTimeout(() => {
requestTermination( 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?.(); executionTimer.unref?.();
refreshIdleTimer(); refreshIdleTimer();
@@ -758,6 +759,14 @@ async function runCodexLiveExecution(payload, response) {
child.stdin?.end(prompt); 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) { function isAuthorized(request) {
const token = String(request.headers['x-access-token'] ?? '').trim(); const token = String(request.headers['x-access-token'] ?? '').trim();
return token.length > 0 && token === accessToken; return token.length > 0 && token === accessToken;

View File

@@ -458,7 +458,7 @@ export function ChatTypeManagementPage() {
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }} autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea" className="chat-type-management-page__markdown-textarea"
placeholder={ placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준' '## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 모바일 브라우저 캡처/검증 기준\n\n## 구현 기준\n- clean code 원칙'
} }
/> />
</Form.Item> </Form.Item>

View File

@@ -201,6 +201,16 @@
color: #92400e; 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 { .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-title {
color: #1d4ed8; color: #1d4ed8;
} }
@@ -274,6 +284,16 @@
0 10px 24px rgba(245, 158, 11, 0.12); 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 { .app-chat-panel__conversation-item--unread {
border-color: rgba(37, 99, 235, 0.18); border-color: rgba(37, 99, 235, 0.18);
background: background:
@@ -294,6 +314,16 @@
0 14px 30px rgba(37, 99, 235, 0.2); 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 { .app-chat-panel__conversation-item--general {
opacity: 0.94; opacity: 0.94;
} }
@@ -353,6 +383,16 @@
0 12px 28px rgba(217, 119, 6, 0.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 { .app-chat-panel__conversation-item--processing.app-chat-panel__conversation-item--unread {
border-color: rgba(147, 51, 234, 0.48); border-color: rgba(147, 51, 234, 0.48);
background: background:

View File

@@ -39,6 +39,7 @@ import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrl
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers'; import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
import { createNotificationMessage } from './notificationApi';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import { import {
ChatConversationView, ChatConversationView,
@@ -113,6 +114,23 @@ type PendingContextConfirm = {
const CHAT_MAX_RETRY_ATTEMPTS = 5; const CHAT_MAX_RETRY_ATTEMPTS = 5;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; 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() { function isStandaloneDisplayMode() {
if (typeof window === 'undefined') { 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) { function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
const normalizedSessionId = sessionId.trim(); const normalizedSessionId = sessionId.trim();
@@ -853,6 +900,10 @@ function isConversationProcessing(item: Pick<ChatConversationSummary, 'currentJo
return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started'; return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
} }
function isConversationFailed(item: Pick<ChatConversationSummary, 'currentJobStatus'>) {
return item.currentJobStatus === 'failed';
}
function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) { function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) {
if (!snapshot) { if (!snapshot) {
return null; return null;
@@ -965,8 +1016,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting'); const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
const shouldRestoreConversationAfterReconnectRef = useRef(false); const shouldRestoreConversationAfterReconnectRef = useRef(false);
const handledRequestedSessionIdRef = useRef(''); const handledRequestedSessionIdRef = useRef('');
const syncedSelectedChatTypeSessionIdRef = useRef<string | null>(null);
const isClosingConversationRef = useRef(false); const isClosingConversationRef = useRef(false);
const notifiedTerminalJobKeysRef = useRef<string[]>([]); const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({}); const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : []; const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => { const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
@@ -1265,12 +1318,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}; };
const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => { const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => {
const sessionId = eventSessionId.trim() || activeSessionId; const sessionId = eventSessionId.trim() || activeSessionId;
let relatedQuestionText = '';
if (incomingMessage.clientRequestId) { if (incomingMessage.clientRequestId) {
const existing = const existing =
requestItemsRef.current.find( requestItemsRef.current.find(
(item) => item.sessionId === sessionId && item.requestId === incomingMessage.clientRequestId, (item) => item.sessionId === sessionId && item.requestId === incomingMessage.clientRequestId,
) ?? null; ) ?? null;
relatedQuestionText = incomingMessage.author === 'codex' ? existing?.userText ?? '' : '';
const hasMeaningfulResponse = const hasMeaningfulResponse =
incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text); 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; 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) { if (incomingMessage.author !== 'codex' || eventConversation?.notifyOffline !== true) {
return; return;
} }
@@ -1432,6 +1521,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const isEffectiveChatTypeAllowed = effectiveRegisteredChatType const isEffectiveChatTypeAllowed = effectiveRegisteredChatType
? canUseChatType(effectiveRegisteredChatType, userRoles) ? canUseChatType(effectiveRegisteredChatType, userRoles)
: false; : false;
const isChatTypeSelectionLocked = Boolean(activeSessionId);
const currentContext: ChatViewContext = { const currentContext: ChatViewContext = {
pageId: currentPage.id, pageId: currentPage.id,
pageTitle: currentPage.title, pageTitle: currentPage.title,
@@ -1596,15 +1686,25 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
); );
}, [conversationItems, conversationSearch]); }, [conversationItems, conversationSearch]);
const unreadConversationItems = useMemo( 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], [filteredConversationItems],
); );
const processingConversationItems = useMemo( const processingConversationItems = useMemo(
() => filteredConversationItems.filter((item) => isConversationProcessing(item) && !item.hasUnreadResponse), () =>
filteredConversationItems.filter(
(item) => isConversationProcessing(item) && !item.hasUnreadResponse && !isConversationFailed(item),
),
[filteredConversationItems], [filteredConversationItems],
); );
const generalConversationItems = useMemo( const generalConversationItems = useMemo(
() => filteredConversationItems.filter((item) => !item.hasUnreadResponse && !isConversationProcessing(item)), () =>
filteredConversationItems.filter(
(item) => !item.hasUnreadResponse && !isConversationProcessing(item) && !isConversationFailed(item),
),
[filteredConversationItems], [filteredConversationItems],
); );
const pendingDeleteConversation = const pendingDeleteConversation =
@@ -1847,11 +1947,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const renderConversationListItem = ( const renderConversationListItem = (
item: ChatConversationSummary, item: ChatConversationSummary,
section: 'processing' | 'unread' | 'general' = 'general', section: 'failed' | 'processing' | 'unread' | 'general' = 'general',
) => { ) => {
const isUnread = item.hasUnreadResponse; const isUnread = item.hasUnreadResponse;
const isProcessing = isConversationProcessing(item); const isProcessing = isConversationProcessing(item);
const isFailed = isConversationFailed(item);
const isUnreadSection = section === 'unread'; const isUnreadSection = section === 'unread';
const isFailedSection = section === 'failed';
return ( return (
<div <div
@@ -1860,6 +1962,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
item.sessionId === activeSessionId ? ' app-chat-panel__conversation-item--active' : '' item.sessionId === activeSessionId ? ' app-chat-panel__conversation-item--active' : ''
}${isUnread ? ' app-chat-panel__conversation-item--unread' : ''}${ }${isUnread ? ' app-chat-panel__conversation-item--unread' : ''}${
isProcessing ? ' app-chat-panel__conversation-item--processing' : '' 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' : ''}${ }${isUnreadSection ? ' app-chat-panel__conversation-item--unread-section' : ''}${
section === 'general' ? ' app-chat-panel__conversation-item--general' : '' section === 'general' ? ' app-chat-panel__conversation-item--general' : ''
}`} }`}
@@ -2312,15 +2416,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return; return;
} }
const persistedChatTypeId = if (syncedSelectedChatTypeSessionIdRef.current !== activeSessionId) {
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null; syncedSelectedChatTypeSessionIdRef.current = activeSessionId;
const persistedChatTypeId =
activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null;
if (persistedChatTypeId) { if (persistedChatTypeId) {
if (selectedChatTypeId !== persistedChatTypeId) { if (selectedChatTypeId !== persistedChatTypeId) {
setSelectedChatTypeId(persistedChatTypeId); setSelectedChatTypeId(persistedChatTypeId);
}
return;
} }
return;
} }
} else {
syncedSelectedChatTypeSessionIdRef.current = null;
} }
if (selectedChatTypeId && availableChatTypes.some((item) => item.id === selectedChatTypeId)) { 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]); }, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
useEffect(() => { useEffect(() => {
if (!activeSessionId || !selectedChatTypeId || !selectedChatType) { if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) {
return; return;
} }
const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null; const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null;
if (currentChatTypeId) { const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
if (currentChatTypeId === selectedChatTypeId && currentLastChatTypeId === selectedChatTypeId) {
return; 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, { void chatGateway.updateConversation(activeSessionId, {
chatTypeId: selectedChatTypeId, chatTypeId: selectedChatTypeId,
lastChatTypeId: selectedChatTypeId, lastChatTypeId: selectedChatTypeId,
@@ -2349,13 +2473,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setConversationItems((previous) => setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)), previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)),
); );
}).catch(() => { }).catch((error: unknown) => {
// Ignore background sync failures and keep local in-memory fallback. 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?.chatTypeId,
activeConversation?.contextDescription,
activeConversation?.contextLabel,
activeConversation?.lastChatTypeId, activeConversation?.lastChatTypeId,
activeSessionId, activeSessionId,
isChatTypeSelectionLocked,
messageApi,
selectedChatType, selectedChatType,
selectedChatTypeId, selectedChatTypeId,
setConversationItems, setConversationItems,
@@ -2914,6 +3056,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<div className="app-chat-panel__conversation-list-body"> <div className="app-chat-panel__conversation-list-body">
{filteredConversationItems.length > 0 ? ( {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 ? ( {unreadConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section"> <div className="app-chat-panel__conversation-section">
<div className="app-chat-panel__conversation-section-header app-chat-panel__conversation-section-header--unread"> <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} ) : null}
{generalConversationItems.length > 0 ? ( {generalConversationItems.length > 0 ? (
<div className="app-chat-panel__conversation-section"> <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"> <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-title"> </span>
<span className="app-chat-panel__conversation-section-count"> <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 }))} previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
isResourceStripOpen={isResourceStripOpen} isResourceStripOpen={isResourceStripOpen}
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed} isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())} isChatTypeSelectionLocked={isChatTypeSelectionLocked}
isComposerAttachmentUploading={isComposerAttachmentUploading} isComposerAttachmentUploading={isComposerAttachmentUploading}
onViewportScroll={handleViewportScroll} onViewportScroll={handleViewportScroll}
onViewportTouchEnd={handleViewportTouchEnd} onViewportTouchEnd={handleViewportTouchEnd}
@@ -3000,7 +3155,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId)); setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId));
}} }}
onSelectChatType={(nextChatTypeId) => { onSelectChatType={(nextChatTypeId) => {
if (activeConversation?.chatTypeId?.trim()) { if (isChatTypeSelectionLocked) {
return; return;
} }

View File

@@ -116,7 +116,11 @@ type InlineFeedback = {
}; };
function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { 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']) { function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['chat']) {
@@ -130,6 +134,10 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
changedLabels.push('최근 문맥 글자 수'); changedLabels.push('최근 문맥 글자 수');
} }
if (saved.codexLiveMaxExecutionSeconds !== draft.codexLiveMaxExecutionSeconds) {
changedLabels.push('Codex Live 최대 실행 시간');
}
return changedLabels; return changedLabels;
} }
@@ -2136,8 +2144,8 @@ export function MainHeader({
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'} message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
description={ description={
chatSettingsDirty chatSettingsDirty
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.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}자까지 채팅 문맥으로 참조` : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초까지 허용`
} }
/> />
@@ -2185,6 +2193,29 @@ export function MainHeader({
}} }}
/> />
</div> </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> </Space>
); );

View File

@@ -18,6 +18,7 @@ export type AppConfig = {
chat: { chat: {
maxContextMessages: number; maxContextMessages: number;
maxContextChars: number; maxContextChars: number;
codexLiveMaxExecutionSeconds: number;
}; };
automation: { automation: {
autoRefreshEnabled: boolean; autoRefreshEnabled: boolean;
@@ -70,6 +71,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
chat: { chat: {
maxContextMessages: 12, maxContextMessages: 12,
maxContextChars: 3200, maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
}, },
automation: { automation: {
autoRefreshEnabled: true, autoRefreshEnabled: true,
@@ -243,6 +245,14 @@ function normalizeChatContextCharLimit(value: number | undefined, fallback: numb
return Math.min(20_000, Math.max(500, Math.round(value))); 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 { function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
const chat = raw?.chat; const chat = raw?.chat;
const automation = raw?.automation; const automation = raw?.automation;
@@ -258,6 +268,10 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
DEFAULT_APP_CONFIG.chat.maxContextMessages, DEFAULT_APP_CONFIG.chat.maxContextMessages,
), ),
maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars), maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars),
codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds(
chat?.codexLiveMaxExecutionSeconds,
DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds,
),
}, },
automation: { automation: {
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled, autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,

View File

@@ -33,7 +33,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request', id: 'general-request',
name: '일반 요청', name: '일반 요청',
description: 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'], permissions: ['token-user'],
enabled: true, enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z', 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, ThunderboltOutlined,
UpOutlined, UpOutlined,
} from '@ant-design/icons'; } 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 type { TextAreaRef } from 'antd/es/input/TextArea';
import { import {
useEffect, useEffect,
@@ -40,6 +40,7 @@ import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul'; 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_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', { const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
timeZone: KST_TIME_ZONE, timeZone: KST_TIME_ZONE,
@@ -840,6 +841,22 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages]; return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]); }, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]); 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(() => { const visiblePreviewItems = useMemo(() => {
if (!showLatestResourceOnly) { if (!showLatestResourceOnly) {
return previewItems; return previewItems;
@@ -1524,9 +1541,14 @@ export function ChatConversationView({
), ),
}))} }))}
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked} disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
onChange={onSelectChatType} onChange={onSelectChatType}
/> />
{normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? (
<Text type="secondary" className="app-chat-panel__composer-type-note">
: {normalizedSelectedChatTypeLabel}
</Text>
) : null}
</div> </div>
<div className="app-chat-panel__composer-actions"> <div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons"> <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) { export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?: string) {
const contextLabelLine = chatTypeLabel ? `선택 컨텍스트: ${chatTypeLabel}` : ''; const normalizedChatTypeLabel = chatTypeLabel?.trim() ?? '';
const contextLabelLine =
normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청'
? `선택 컨텍스트: ${normalizedChatTypeLabel}`
: '';
const contextDescriptionLine = chatTypeDescription ? `기본 문맥: ${chatTypeDescription}` : ''; const contextDescriptionLine = chatTypeDescription ? `기본 문맥: ${chatTypeDescription}` : '';
return createChatMessage( return createChatMessage(
@@ -525,7 +529,11 @@ export function createIntroMessage(chatTypeLabel?: string, chatTypeDescription?:
export function buildOfflineReply(context: ChatViewContext, input: string) { export function buildOfflineReply(context: ChatViewContext, input: string) {
const normalized = input.toLowerCase(); 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}` : ''; const descriptionLine = context.chatTypeDescription ? `- 기본 문맥: ${context.chatTypeDescription}` : '';
if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) {